feat(build): KMP → Android-only migration (Phase 1.2)
- Delete desktopApp/ and shared/ modules.
- Merge shared commonMain + androidMain + readerJvmMain sources into app/src/main/kotlin.
- Resolve expect/actual declarations by keeping android implementations.
- Remove project(':shared') dependency and KMP plugins from build files.
- Simplify settings.gradle.kts to single :app module; root project renamed BookReader.
- Remove JVM-only code (javax.imageio/ImageIO in SharedJvmBookLoader).
- Make LocalFolderSync helpers public for cross-package use in Android module.
- Baseline ':app:assembleOssDebug' passes (APK ~80 MB).
This commit is contained in:
parent
ab9a2dcfeb
commit
0d9439e8c3
323 changed files with 74 additions and 50598 deletions
|
|
@ -217,8 +217,6 @@ kover {
|
|||
//noinspection UseTomlInstead
|
||||
dependencies {
|
||||
|
||||
implementation(project(":shared"))
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
|
|
|||
12
app/src/main/kotlin/androidx/compose/material/icons/Icons.kt
Normal file
12
app/src/main/kotlin/androidx/compose/material/icons/Icons.kt
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package androidx.compose.material.icons
|
||||
|
||||
object Icons {
|
||||
object Filled
|
||||
val Default: Filled get() = Filled
|
||||
|
||||
object Outlined
|
||||
|
||||
object AutoMirrored {
|
||||
object Filled
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
@file:Suppress("ObjectPropertyName", "unused")
|
||||
|
||||
package androidx.compose.material.icons.automirrored.filled
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.PathParser
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val Icons.AutoMirrored.Filled.ArrowBack: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.arrowBack
|
||||
|
||||
val Icons.AutoMirrored.Filled.ArrowForward: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.arrowForward
|
||||
|
||||
val Icons.AutoMirrored.Filled.KeyboardArrowRight: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.keyboardArrowRight
|
||||
|
||||
val Icons.AutoMirrored.Filled.LibraryBooks: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.libraryBooks
|
||||
|
||||
val Icons.AutoMirrored.Filled.List: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.list
|
||||
|
||||
val Icons.AutoMirrored.Filled.MenuBook: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.menuBook
|
||||
|
||||
val Icons.AutoMirrored.Filled.NavigateBefore: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.navigateBefore
|
||||
|
||||
val Icons.AutoMirrored.Filled.NavigateNext: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.navigateNext
|
||||
|
||||
val Icons.AutoMirrored.Filled.OpenInNew: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.openInNew
|
||||
|
||||
val Icons.AutoMirrored.Filled.Redo: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.redo
|
||||
|
||||
val Icons.AutoMirrored.Filled.Sort: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.sort
|
||||
|
||||
val Icons.AutoMirrored.Filled.Undo: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.undo
|
||||
|
||||
val Icons.AutoMirrored.Filled.VolumeUp: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.volumeUp
|
||||
|
||||
private object EpistemeAutoMirroredFilledIcons {
|
||||
val arrowBack: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "ArrowBack",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M313,520L537,744L480,800L160,480L480,160L537,216L313,440L800,440L800,520L313,520Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val arrowForward: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "ArrowForward",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M647,520L160,520L160,440L647,440L423,216L480,160L800,480L480,800L423,744L647,520Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val keyboardArrowRight: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "KeyboardArrowRight",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M504,480L320,296L376,240L616,480L376,720L320,664L504,480Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val libraryBooks: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "LibraryBooks",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M400,560L560,560L560,480L400,480L400,560ZM400,440L720,440L720,360L400,360L400,440ZM400,320L720,320L720,240L400,240L400,320ZM320,720Q287,720 263.5,696.5Q240,673 240,640L240,160Q240,127 263.5,103.5Q287,80 320,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L320,720ZM320,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L320,160Q320,160 320,160Q320,160 320,160L320,640Q320,640 320,640Q320,640 320,640ZM160,880Q127,880 103.5,856.5Q80,833 80,800L80,240L160,240L160,800Q160,800 160,800Q160,800 160,800L720,800L720,880L160,880ZM320,160L320,160Q320,160 320,160Q320,160 320,160L320,640Q320,640 320,640Q320,640 320,640L320,640Q320,640 320,640Q320,640 320,640L320,160Q320,160 320,160Q320,160 320,160Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val list: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "List",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M280,360L280,280L840,280L840,360L280,360ZM280,520L280,440L840,440L840,520L280,520ZM280,680L280,600L840,600L840,680L280,680ZM160,360Q143,360 131.5,348.5Q120,337 120,320Q120,303 131.5,291.5Q143,280 160,280Q177,280 188.5,291.5Q200,303 200,320Q200,337 188.5,348.5Q177,360 160,360ZM160,520Q143,520 131.5,508.5Q120,497 120,480Q120,463 131.5,451.5Q143,440 160,440Q177,440 188.5,451.5Q200,463 200,480Q200,497 188.5,508.5Q177,520 160,520ZM160,680Q143,680 131.5,668.5Q120,657 120,640Q120,623 131.5,611.5Q143,600 160,600Q177,600 188.5,611.5Q200,623 200,640Q200,657 188.5,668.5Q177,680 160,680Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val menuBook: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "MenuBook",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M560,396L560,328Q593,314 627.5,307Q662,300 700,300Q726,300 751,304Q776,308 800,314L800,378Q776,369 751.5,364.5Q727,360 700,360Q662,360 627,369.5Q592,379 560,396ZM560,616L560,548Q593,534 627.5,527Q662,520 700,520Q726,520 751,524Q776,528 800,534L800,598Q776,589 751.5,584.5Q727,580 700,580Q662,580 627,589Q592,598 560,616ZM560,506L560,438Q593,424 627.5,417Q662,410 700,410Q726,410 751,414Q776,418 800,424L800,488Q776,479 751.5,474.5Q727,470 700,470Q662,470 627,479.5Q592,489 560,506ZM260,640Q307,640 351.5,650.5Q396,661 440,682L440,288Q399,264 353,252Q307,240 260,240Q224,240 188.5,247Q153,254 120,268Q120,268 120,268Q120,268 120,268L120,664Q120,664 120,664Q120,664 120,664Q155,652 189.5,646Q224,640 260,640ZM520,682Q564,661 608.5,650.5Q653,640 700,640Q736,640 770.5,646Q805,652 840,664Q840,664 840,664Q840,664 840,664L840,268Q840,268 840,268Q840,268 840,268Q807,254 771.5,247Q736,240 700,240Q653,240 607,252Q561,264 520,288L520,682ZM480,800Q432,762 376,741Q320,720 260,720Q218,720 177.5,731Q137,742 100,762Q79,773 59.5,761Q40,749 40,726L40,244Q40,233 45.5,223Q51,213 62,208Q108,184 158,172Q208,160 260,160Q318,160 373.5,175Q429,190 480,220Q531,190 586.5,175Q642,160 700,160Q752,160 802,172Q852,184 898,208Q909,213 914.5,223Q920,233 920,244L920,726Q920,749 900.5,761Q881,773 860,762Q823,742 782.5,731Q742,720 700,720Q640,720 584,741Q528,762 480,800ZM280,466Q280,466 280,466Q280,466 280,466L280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466L280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val navigateBefore: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "NavigateBefore",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M560,720L320,480L560,240L616,296L432,480L616,664L560,720Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val navigateNext: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "NavigateNext",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M504,480L320,296L376,240L616,480L376,720L320,664L504,480Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val openInNew: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "OpenInNew",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L480,120L480,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L760,760Q760,760 760,760Q760,760 760,760L760,480L840,480L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840ZM388,628L332,572L704,200L560,200L560,120L840,120L840,400L760,400L760,256L388,628Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val redo: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "Redo",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M396,760Q299,760 229.5,697Q160,634 160,540Q160,446 229.5,383Q299,320 396,320L648,320L544,216L600,160L800,360L600,560L544,504L648,400L396,400Q333,400 286.5,440Q240,480 240,540Q240,600 286.5,640Q333,680 396,680L680,680L680,760L396,760Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val sort: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "Sort",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M120,720L120,640L360,640L360,720L120,720ZM120,520L120,440L600,440L600,520L120,520ZM120,320L120,240L840,240L840,320L120,320Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val undo: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "Undo",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M280,760L280,680L564,680Q627,680 673.5,640Q720,600 720,540Q720,480 673.5,440Q627,400 564,400L312,400L416,504L360,560L160,360L360,160L416,216L312,320L564,320Q661,320 730.5,383Q800,446 800,540Q800,634 730.5,697Q661,760 564,760L280,760Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val volumeUp: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "VolumeUp",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M560,829L560,747Q650,721 705,647Q760,573 760,479Q760,385 705,311Q650,237 560,211L560,129Q684,157 762,254.5Q840,352 840,479Q840,606 762,703.5Q684,801 560,829ZM120,600L120,360L280,360L480,160L480,800L280,600L120,600ZM560,640L560,318Q607,340 633.5,384Q660,428 660,480Q660,531 633.5,574.5Q607,618 560,640ZM400,354L314,440L200,440L200,520L314,520L400,606L400,354ZM300,480L300,480L300,480L300,480L300,480L300,480Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun materialIcon(
|
||||
name: String,
|
||||
defaultWidth: Float,
|
||||
defaultHeight: Float,
|
||||
viewportWidth: Float,
|
||||
viewportHeight: Float,
|
||||
autoMirror: Boolean,
|
||||
paths: List<String>
|
||||
): ImageVector {
|
||||
return ImageVector.Builder(
|
||||
name = name,
|
||||
defaultWidth = defaultWidth.dp,
|
||||
defaultHeight = defaultHeight.dp,
|
||||
viewportWidth = viewportWidth,
|
||||
viewportHeight = viewportHeight,
|
||||
autoMirror = autoMirror
|
||||
).apply {
|
||||
paths.forEach { pathData ->
|
||||
addPath(
|
||||
pathData = PathParser().parsePathString(pathData).toNodes(),
|
||||
fill = SolidColor(Color.Black)
|
||||
)
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,159 @@
|
|||
@file:Suppress("ObjectPropertyName", "unused")
|
||||
|
||||
package androidx.compose.material.icons.outlined
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.PathParser
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val Icons.Outlined.AccountCircle: ImageVector
|
||||
get() = EpistemeOutlinedIcons.accountCircle
|
||||
|
||||
val Icons.Outlined.Email: ImageVector
|
||||
get() = EpistemeOutlinedIcons.email
|
||||
|
||||
val Icons.Outlined.FavoriteBorder: ImageVector
|
||||
get() = EpistemeOutlinedIcons.favoriteBorder
|
||||
|
||||
val Icons.Outlined.Feedback: ImageVector
|
||||
get() = EpistemeOutlinedIcons.feedback
|
||||
|
||||
val Icons.Outlined.FileOpen: ImageVector
|
||||
get() = EpistemeOutlinedIcons.fileOpen
|
||||
|
||||
val Icons.Outlined.Gavel: ImageVector
|
||||
get() = EpistemeOutlinedIcons.gavel
|
||||
|
||||
val Icons.Outlined.Policy: ImageVector
|
||||
get() = EpistemeOutlinedIcons.policy
|
||||
|
||||
private object EpistemeOutlinedIcons {
|
||||
val accountCircle: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "AccountCircle",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = false,
|
||||
paths = listOf(
|
||||
"""M234,684Q285,645 348,622.5Q411,600 480,600Q549,600 612,622.5Q675,645 726,684Q761,643 780.5,591Q800,539 800,480Q800,347 706.5,253.5Q613,160 480,160Q347,160 253.5,253.5Q160,347 160,480Q160,539 179.5,591Q199,643 234,684ZM380.5,479.5Q340,439 340,380Q340,321 380.5,280.5Q421,240 480,240Q539,240 579.5,280.5Q620,321 620,380Q620,439 579.5,479.5Q539,520 480,520Q421,520 380.5,479.5ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM580,784.5Q627,769 666,740Q627,711 580,695.5Q533,680 480,680Q427,680 380,695.5Q333,711 294,740Q333,769 380,784.5Q427,800 480,800Q533,800 580,784.5ZM523,423Q540,406 540,380Q540,354 523,337Q506,320 480,320Q454,320 437,337Q420,354 420,380Q420,406 437,423Q454,440 480,440Q506,440 523,423ZM480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380ZM480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val email: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "Email",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = false,
|
||||
paths = listOf(
|
||||
"""M160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L800,160Q833,160 856.5,183.5Q880,207 880,240L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM480,520L160,320L160,720Q160,720 160,720Q160,720 160,720L800,720Q800,720 800,720Q800,720 800,720L800,320L480,520ZM480,440L800,240L160,240L480,440ZM160,320L160,240L160,240L160,320L160,720Q160,720 160,720Q160,720 160,720L160,720Q160,720 160,720Q160,720 160,720L160,320Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val favoriteBorder: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "FavoriteBorder",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = false,
|
||||
paths = listOf(
|
||||
"""M480,840L422,788Q321,697 255,631Q189,565 150,512.5Q111,460 95.5,416Q80,372 80,326Q80,232 143,169Q206,106 300,106Q352,106 399,128Q446,150 480,190Q514,150 561,128Q608,106 660,106Q754,106 817,169Q880,232 880,326Q880,372 864.5,416Q849,460 810,512.5Q771,565 705,631Q639,697 538,788L480,840ZM480,732Q576,646 638,584.5Q700,523 736,477.5Q772,432 786,396.5Q800,361 800,326Q800,266 760,226Q720,186 660,186Q613,186 573,212.5Q533,239 518,280L518,280L442,280L442,280Q427,239 387,212.5Q347,186 300,186Q240,186 200,226Q160,266 160,326Q160,361 174,396.5Q188,432 224,477.5Q260,523 322,584.5Q384,646 480,732ZM480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459L480,459L480,459L480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val feedback: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "Feedback",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = false,
|
||||
paths = listOf(
|
||||
"""M480,600Q497,600 508.5,588.5Q520,577 520,560Q520,543 508.5,531.5Q497,520 480,520Q463,520 451.5,531.5Q440,543 440,560Q440,577 451.5,588.5Q463,600 480,600ZM440,440L520,440L520,200L440,200L440,440ZM80,880L80,160Q80,127 103.5,103.5Q127,80 160,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L240,720L80,880ZM206,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L160,160Q160,160 160,160Q160,160 160,160L160,685L206,640ZM160,640L160,640L160,160Q160,160 160,160Q160,160 160,160L160,160Q160,160 160,160Q160,160 160,160L160,640Q160,640 160,640Q160,640 160,640Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val fileOpen: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "FileOpen",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = false,
|
||||
paths = listOf(
|
||||
"""M240,880Q207,880 183.5,856.5Q160,833 160,800L160,160Q160,127 183.5,103.5Q207,80 240,80L560,80L800,320L800,560L720,560L720,360L520,360L520,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800L600,800L600,880L240,880ZM878,895L760,777L760,866L680,866L680,640L906,640L906,720L816,720L934,838L878,895ZM240,800L240,560L240,560L240,360L240,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val gavel: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "Gavel",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = false,
|
||||
paths = listOf(
|
||||
"""M160,840L160,760L640,760L640,840L160,840ZM386,646L160,420L244,334L472,560L386,646ZM640,392L414,164L500,80L726,306L640,392ZM824,800L302,278L358,222L880,744L824,800Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val policy: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "Policy",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = false,
|
||||
paths = listOf(
|
||||
"""M480,880Q341,845 250.5,720.5Q160,596 160,444L160,200L480,80L800,200L800,444Q800,529 771,607.5Q742,686 688,746L560,618Q542,629 521.5,634.5Q501,640 480,640Q414,640 367,593Q320,546 320,480Q320,414 367,367Q414,320 480,320Q546,320 593,367Q640,414 640,480Q640,502 634.5,522.5Q629,543 618,562L678,622Q698,581 709,536Q720,491 720,444L720,255L480,165L240,255L240,444Q240,565 308,664Q376,763 480,796Q506,788 529.5,775.5Q553,763 576,746L632,802Q599,829 560.5,849Q522,869 480,880ZM536.5,536.5Q560,513 560,480Q560,447 536.5,423.5Q513,400 480,400Q447,400 423.5,423.5Q400,447 400,480Q400,513 423.5,536.5Q447,560 480,560Q513,560 536.5,536.5ZM488,483L488,483Q488,483 488,483Q488,483 488,483L488,483Q488,483 488,483Q488,483 488,483L488,483L488,483L488,483L488,483Q488,483 488,483Q488,483 488,483Q488,483 488,483Q488,483 488,483Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun materialIcon(
|
||||
name: String,
|
||||
defaultWidth: Float,
|
||||
defaultHeight: Float,
|
||||
viewportWidth: Float,
|
||||
viewportHeight: Float,
|
||||
autoMirror: Boolean,
|
||||
paths: List<String>
|
||||
): ImageVector {
|
||||
return ImageVector.Builder(
|
||||
name = name,
|
||||
defaultWidth = defaultWidth.dp,
|
||||
defaultHeight = defaultHeight.dp,
|
||||
viewportWidth = viewportWidth,
|
||||
viewportHeight = viewportHeight,
|
||||
autoMirror = autoMirror
|
||||
).apply {
|
||||
paths.forEach { pathData ->
|
||||
addPath(
|
||||
pathData = PathParser().parsePathString(pathData).toNodes(),
|
||||
fill = SolidColor(Color.Black)
|
||||
)
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,39 @@
|
|||
package org.dueattendant149.bookreader.paginatedreader
|
||||
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
|
||||
/**
|
||||
* Shared mapper for generic CSS font-family names.
|
||||
*
|
||||
* Platform-specific font loaders can still resolve embedded/custom font files and add their own
|
||||
* FontFamily instances, but generic CSS families should behave consistently everywhere.
|
||||
*/
|
||||
object FontFamilyMapper {
|
||||
private val genericFontMap = mapOf(
|
||||
"serif" to FontFamily.Serif,
|
||||
"sans-serif" to FontFamily.SansSerif,
|
||||
"monospace" to FontFamily.Monospace,
|
||||
"cursive" to FontFamily.Cursive,
|
||||
"default" to FontFamily.Default,
|
||||
"system-ui" to FontFamily.Default,
|
||||
"ui-sans-serif" to FontFamily.Default,
|
||||
"ui-serif" to FontFamily.Default,
|
||||
"ui-monospace" to FontFamily.Default,
|
||||
"ui-rounded" to FontFamily.Default
|
||||
)
|
||||
|
||||
fun nameToFontFamily(name: String): FontFamily? {
|
||||
return genericFontMap[name.trim().lowercase()]
|
||||
}
|
||||
|
||||
fun fontFamilyToName(fontFamily: FontFamily): String? {
|
||||
return when (fontFamily) {
|
||||
FontFamily.Serif -> "serif"
|
||||
FontFamily.SansSerif -> "sans-serif"
|
||||
FontFamily.Monospace -> "monospace"
|
||||
FontFamily.Cursive -> "cursive"
|
||||
FontFamily.Default -> "default"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,448 @@
|
|||
/*
|
||||
* Episteme Reader - A native Android document reader.
|
||||
* Copyright (C) 2026 Episteme
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* mail: epistemereader@gmail.com
|
||||
*/
|
||||
@file:OptIn(ExperimentalSerializationApi::class)
|
||||
package org.dueattendant149.bookreader.paginatedreader
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.ParagraphStyle
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.isSpecified
|
||||
import org.dueattendant149.bookreader.paginatedreader.serialization.AnnotatedStringSerializer
|
||||
import org.dueattendant149.bookreader.paginatedreader.serialization.ColorSerializer
|
||||
import org.dueattendant149.bookreader.paginatedreader.serialization.DpSerializer
|
||||
import org.dueattendant149.bookreader.paginatedreader.serialization.ParagraphStyleSerializer
|
||||
import org.dueattendant149.bookreader.paginatedreader.serialization.SpanStyleSerializer
|
||||
import org.dueattendant149.bookreader.paginatedreader.serialization.TextAlignSerializer
|
||||
import org.dueattendant149.bookreader.paginatedreader.serialization.TextUnitSerializer
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.protobuf.ProtoNumber
|
||||
|
||||
@Serializable
|
||||
data class BlockStyle(
|
||||
@ProtoNumber(1) val margin: BoxBorders = BoxBorders(),
|
||||
@ProtoNumber(2) val padding: BoxBorders = BoxBorders(),
|
||||
@ProtoNumber(3) @Serializable(with = DpSerializer::class) val width: Dp = Dp.Unspecified,
|
||||
@ProtoNumber(4) @Serializable(with = DpSerializer::class) val maxWidth: Dp = Dp.Unspecified,
|
||||
@ProtoNumber(5) @Serializable(with = DpSerializer::class) val height: Dp = Dp.Unspecified,
|
||||
@ProtoNumber(6) @Serializable(with = ColorSerializer::class) val backgroundColor: Color = Color.Unspecified,
|
||||
@ProtoNumber(7) val borderTop: BorderStyle? = null,
|
||||
@ProtoNumber(8) val borderRight: BorderStyle? = null,
|
||||
@ProtoNumber(9) val borderBottom: BorderStyle? = null,
|
||||
@ProtoNumber(10) val borderLeft: BorderStyle? = null,
|
||||
@ProtoNumber(11) val listStyleType: String? = null,
|
||||
@ProtoNumber(12) val listStyleImage: String? = null,
|
||||
@ProtoNumber(13) val pageBreakInsideAvoid: Boolean = false,
|
||||
@ProtoNumber(14) val pageBreakAfterAvoid: Boolean = false,
|
||||
@ProtoNumber(15) val boxSizing: String? = null,
|
||||
@ProtoNumber(16) val float: String? = null,
|
||||
@ProtoNumber(17) val clear: String? = null,
|
||||
@ProtoNumber(18) val position: String? = null,
|
||||
@ProtoNumber(19) @Serializable(with = DpSerializer::class) val top: Dp = Dp.Unspecified,
|
||||
@ProtoNumber(20) @Serializable(with = DpSerializer::class) val right: Dp = Dp.Unspecified,
|
||||
@ProtoNumber(21) @Serializable(with = DpSerializer::class) val bottom: Dp = Dp.Unspecified,
|
||||
@ProtoNumber(22) @Serializable(with = DpSerializer::class) val left: Dp = Dp.Unspecified,
|
||||
@ProtoNumber(23) val display: String? = null,
|
||||
@ProtoNumber(24) val flexDirection: String? = null,
|
||||
@ProtoNumber(25) val justifyContent: String? = null,
|
||||
@ProtoNumber(26) val alignItems: String? = null,
|
||||
@ProtoNumber(27) val horizontalAlign: String? = null,
|
||||
@ProtoNumber(28) val filter: String? = null,
|
||||
@ProtoNumber(29) val borderCollapse: String? = null,
|
||||
@ProtoNumber(30) @Serializable(with = DpSerializer::class) val borderTopLeftRadius: Dp = 0.dp,
|
||||
@ProtoNumber(31) @Serializable(with = DpSerializer::class) val borderTopRightRadius: Dp = 0.dp,
|
||||
@ProtoNumber(32) @Serializable(with = DpSerializer::class) val borderBottomRightRadius: Dp = 0.dp,
|
||||
@ProtoNumber(33) @Serializable(with = DpSerializer::class) val borderBottomLeftRadius: Dp = 0.dp,
|
||||
@ProtoNumber(34) @Serializable(with = DpSerializer::class) val borderSpacing: Dp = 0.dp,
|
||||
@ProtoNumber(35) @Serializable(with = DpSerializer::class) val minWidth: Dp = Dp.Unspecified,
|
||||
@ProtoNumber(36) @Serializable(with = DpSerializer::class) val minHeight: Dp = Dp.Unspecified,
|
||||
@ProtoNumber(37) @Serializable(with = DpSerializer::class) val maxHeight: Dp = Dp.Unspecified,
|
||||
@ProtoNumber(38) val overflow: String? = null,
|
||||
@ProtoNumber(39) val breakBefore: String? = null,
|
||||
@ProtoNumber(40) val breakAfter: String? = null,
|
||||
@ProtoNumber(41) val breakInside: String? = null,
|
||||
@ProtoNumber(42) val widows: Int = 2,
|
||||
@ProtoNumber(43) val orphans: Int = 2,
|
||||
@ProtoNumber(44) val visibility: String? = null,
|
||||
@ProtoNumber(45) val objectFit: String? = null,
|
||||
@ProtoNumber(46) val objectPosition: String? = null,
|
||||
@ProtoNumber(47) val backgroundImage: String? = null
|
||||
) {
|
||||
fun merge(other: BlockStyle): BlockStyle {
|
||||
return BlockStyle(
|
||||
margin = BoxBorders(
|
||||
top = if (other.margin.top != 0.dp) other.margin.top else this.margin.top,
|
||||
bottom = if (other.margin.bottom != 0.dp) other.margin.bottom else this.margin.bottom,
|
||||
left = if (other.margin.left != 0.dp) other.margin.left else this.margin.left,
|
||||
right = if (other.margin.right != 0.dp) other.margin.right else this.margin.right
|
||||
),
|
||||
padding = BoxBorders(
|
||||
top = if (other.padding.top != 0.dp) other.padding.top else this.padding.top,
|
||||
bottom = if (other.padding.bottom != 0.dp) other.padding.bottom else this.padding.bottom,
|
||||
left = if (other.padding.left != 0.dp) other.padding.left else this.padding.left,
|
||||
right = if (other.padding.right != 0.dp) other.padding.right else this.padding.right
|
||||
),
|
||||
width = if (other.width != Dp.Unspecified) other.width else this.width,
|
||||
maxWidth = if (other.maxWidth != Dp.Unspecified) other.maxWidth else this.maxWidth,
|
||||
height = if (other.height != Dp.Unspecified) other.height else this.height,
|
||||
backgroundColor = if (other.backgroundColor.isSpecified) other.backgroundColor else this.backgroundColor,
|
||||
borderTop = other.borderTop ?: this.borderTop,
|
||||
borderRight = other.borderRight ?: this.borderRight,
|
||||
borderBottom = other.borderBottom ?: this.borderBottom,
|
||||
borderLeft = other.borderLeft ?: this.borderLeft,
|
||||
borderTopLeftRadius = if (other.borderTopLeftRadius != 0.dp) other.borderTopLeftRadius else this.borderTopLeftRadius,
|
||||
borderTopRightRadius = if (other.borderTopRightRadius != 0.dp) other.borderTopRightRadius else this.borderTopRightRadius,
|
||||
borderBottomRightRadius = if (other.borderBottomRightRadius != 0.dp) other.borderBottomRightRadius else this.borderBottomRightRadius,
|
||||
borderBottomLeftRadius = if (other.borderBottomLeftRadius != 0.dp) other.borderBottomLeftRadius else this.borderBottomLeftRadius,
|
||||
listStyleType = other.listStyleType ?: this.listStyleType,
|
||||
listStyleImage = other.listStyleImage ?: this.listStyleImage,
|
||||
pageBreakInsideAvoid = this.pageBreakInsideAvoid || other.pageBreakInsideAvoid,
|
||||
pageBreakAfterAvoid = this.pageBreakAfterAvoid || other.pageBreakAfterAvoid,
|
||||
boxSizing = other.boxSizing ?: this.boxSizing,
|
||||
float = other.float ?: this.float,
|
||||
clear = other.clear ?: this.clear,
|
||||
position = other.position ?: this.position,
|
||||
top = if (other.top.isSpecified) other.top else this.top,
|
||||
right = if (other.right.isSpecified) other.right else this.right,
|
||||
bottom = if (other.bottom.isSpecified) other.bottom else this.bottom,
|
||||
left = if (other.left.isSpecified) other.left else this.left,
|
||||
display = other.display ?: this.display,
|
||||
flexDirection = other.flexDirection ?: this.flexDirection,
|
||||
justifyContent = other.justifyContent ?: this.justifyContent,
|
||||
alignItems = other.alignItems ?: this.alignItems,
|
||||
horizontalAlign = other.horizontalAlign ?: this.horizontalAlign,
|
||||
filter = other.filter ?: this.filter,
|
||||
borderCollapse = other.borderCollapse ?: this.borderCollapse,
|
||||
borderSpacing = if (other.borderSpacing != 0.dp) other.borderSpacing else this.borderSpacing,
|
||||
minWidth = if (other.minWidth.isSpecified) other.minWidth else this.minWidth,
|
||||
minHeight = if (other.minHeight.isSpecified) other.minHeight else this.minHeight,
|
||||
maxHeight = if (other.maxHeight.isSpecified) other.maxHeight else this.maxHeight,
|
||||
overflow = other.overflow ?: this.overflow,
|
||||
breakBefore = other.breakBefore ?: this.breakBefore,
|
||||
breakAfter = other.breakAfter ?: this.breakAfter,
|
||||
breakInside = other.breakInside ?: this.breakInside,
|
||||
widows = if (other.widows != 2) other.widows else this.widows,
|
||||
orphans = if (other.orphans != 2) other.orphans else this.orphans,
|
||||
visibility = other.visibility ?: this.visibility,
|
||||
objectFit = other.objectFit ?: this.objectFit,
|
||||
objectPosition = other.objectPosition ?: this.objectPosition,
|
||||
backgroundImage = other.backgroundImage ?: this.backgroundImage
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class BoxBorders(
|
||||
@ProtoNumber(1) @Serializable(with = DpSerializer::class) val top: Dp = 0.dp,
|
||||
@ProtoNumber(2) @Serializable(with = DpSerializer::class) val right: Dp = 0.dp,
|
||||
@ProtoNumber(3) @Serializable(with = DpSerializer::class) val bottom: Dp = 0.dp,
|
||||
@ProtoNumber(4) @Serializable(with = DpSerializer::class) val left: Dp = 0.dp
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BorderStyle(
|
||||
@ProtoNumber(1) @Serializable(with = DpSerializer::class) val width: Dp = 0.dp,
|
||||
@ProtoNumber(2) @Serializable(with = ColorSerializer::class) val color: Color = Color.Transparent,
|
||||
@ProtoNumber(3) val style: String = "solid"
|
||||
)
|
||||
|
||||
@Serializable
|
||||
sealed interface ContentBlock {
|
||||
val style: BlockStyle
|
||||
val elementId: String?
|
||||
val cfi: String?
|
||||
val blockIndex: Int
|
||||
val expectedHeight: Int
|
||||
}
|
||||
|
||||
sealed interface TextContentBlock : ContentBlock {
|
||||
val content: AnnotatedString
|
||||
val startCharOffsetInSource: Int
|
||||
val endCharOffsetInSource: Int
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ParagraphBlock(
|
||||
@ProtoNumber(1) @Serializable(with = AnnotatedStringSerializer::class) override val content: AnnotatedString,
|
||||
@ProtoNumber(2) @Serializable(with = TextAlignSerializer::class) val textAlign: TextAlign? = null,
|
||||
@ProtoNumber(3) override val style: BlockStyle = BlockStyle(),
|
||||
@ProtoNumber(4) override val elementId: String? = null,
|
||||
@ProtoNumber(5) override val cfi: String? = null,
|
||||
@ProtoNumber(6) override val startCharOffsetInSource: Int = 0,
|
||||
@ProtoNumber(7) override val endCharOffsetInSource: Int = -1,
|
||||
@ProtoNumber(8) override val blockIndex: Int,
|
||||
@ProtoNumber(9) override val expectedHeight: Int = 0
|
||||
) : TextContentBlock
|
||||
|
||||
@Serializable
|
||||
data class ImageBlock(
|
||||
@ProtoNumber(1) val path: String,
|
||||
@ProtoNumber(2) val altText: String?,
|
||||
@ProtoNumber(3) val intrinsicWidth: Float? = null,
|
||||
@ProtoNumber(4) val intrinsicHeight: Float? = null,
|
||||
@ProtoNumber(5) override val style: BlockStyle = BlockStyle(),
|
||||
@ProtoNumber(6) override val elementId: String? = null,
|
||||
@ProtoNumber(7) override val cfi: String? = null,
|
||||
@ProtoNumber(8) val invertOnDarkTheme: Boolean = false,
|
||||
@ProtoNumber(9) override val blockIndex: Int,
|
||||
@ProtoNumber(10) override val expectedHeight: Int = 0
|
||||
) : ContentBlock
|
||||
|
||||
@Serializable
|
||||
data class HeaderBlock(
|
||||
@ProtoNumber(1) val level: Int,
|
||||
@ProtoNumber(2) @Serializable(with = AnnotatedStringSerializer::class) override val content: AnnotatedString,
|
||||
@ProtoNumber(3) @Serializable(with = TextAlignSerializer::class) val textAlign: TextAlign? = null,
|
||||
@ProtoNumber(4) override val style: BlockStyle = BlockStyle(),
|
||||
@ProtoNumber(5) override val elementId: String? = null,
|
||||
@ProtoNumber(6) override val cfi: String? = null,
|
||||
@ProtoNumber(7) override val startCharOffsetInSource: Int = 0,
|
||||
@ProtoNumber(8) override val endCharOffsetInSource: Int = -1,
|
||||
@ProtoNumber(9) override val blockIndex: Int,
|
||||
@ProtoNumber(10) override val expectedHeight: Int = 0
|
||||
) : TextContentBlock
|
||||
|
||||
@Serializable
|
||||
data class SpacerBlock(
|
||||
@ProtoNumber(1) @Serializable(with = DpSerializer::class) val height: Dp = 8.dp,
|
||||
@ProtoNumber(2) override val style: BlockStyle = BlockStyle(),
|
||||
@ProtoNumber(3) override val elementId: String? = null,
|
||||
@ProtoNumber(4) override val cfi: String? = null,
|
||||
@ProtoNumber(5) override val blockIndex: Int,
|
||||
@ProtoNumber(6) override val expectedHeight: Int = 0
|
||||
) : ContentBlock
|
||||
|
||||
@Serializable
|
||||
data class QuoteBlock(
|
||||
@ProtoNumber(1) @Serializable(with = AnnotatedStringSerializer::class) override val content: AnnotatedString,
|
||||
@ProtoNumber(2) @Serializable(with = TextAlignSerializer::class) val textAlign: TextAlign? = null,
|
||||
@ProtoNumber(3) override val style: BlockStyle = BlockStyle(),
|
||||
@ProtoNumber(4) override val elementId: String? = null,
|
||||
@ProtoNumber(5) override val cfi: String? = null,
|
||||
@ProtoNumber(6) override val startCharOffsetInSource: Int = 0,
|
||||
@ProtoNumber(7) override val endCharOffsetInSource: Int = -1,
|
||||
@ProtoNumber(8) override val blockIndex: Int,
|
||||
@ProtoNumber(9) override val expectedHeight: Int = 0
|
||||
) : TextContentBlock
|
||||
|
||||
@Serializable
|
||||
data class ListItemBlock(
|
||||
@ProtoNumber(1) @Serializable(with = AnnotatedStringSerializer::class) override val content: AnnotatedString,
|
||||
@ProtoNumber(2) val itemMarker: String?,
|
||||
@ProtoNumber(3) val itemMarkerImage: String? = null,
|
||||
@ProtoNumber(4) override val style: BlockStyle,
|
||||
@ProtoNumber(5) override val elementId: String? = null,
|
||||
@ProtoNumber(6) override val cfi: String? = null,
|
||||
@ProtoNumber(7) override val startCharOffsetInSource: Int = 0,
|
||||
@ProtoNumber(8) override val endCharOffsetInSource: Int = -1,
|
||||
@ProtoNumber(9) override val blockIndex: Int,
|
||||
@ProtoNumber(10) override val expectedHeight: Int = 0
|
||||
) : TextContentBlock
|
||||
|
||||
@Serializable
|
||||
data class TableCell(
|
||||
@ProtoNumber(1) val content: List<ContentBlock>,
|
||||
@ProtoNumber(2) val isHeader: Boolean = false,
|
||||
@ProtoNumber(3) val style: CssStyle = CssStyle(),
|
||||
@ProtoNumber(4) val colspan: Int = 1
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TableBlock(
|
||||
@ProtoNumber(1) val rows: List<List<TableCell>>,
|
||||
@ProtoNumber(2) override val style: BlockStyle = BlockStyle(),
|
||||
@ProtoNumber(3) override val elementId: String? = null,
|
||||
@ProtoNumber(4) override val cfi: String? = null,
|
||||
@ProtoNumber(5) override val blockIndex: Int,
|
||||
@ProtoNumber(6) override val expectedHeight: Int = 0
|
||||
) : ContentBlock
|
||||
|
||||
@Serializable
|
||||
data class MathBlock(
|
||||
@ProtoNumber(1) val svgContent: String?,
|
||||
@ProtoNumber(2) val altText: String?,
|
||||
@ProtoNumber(3) override val style: BlockStyle,
|
||||
@ProtoNumber(4) override val elementId: String?,
|
||||
@ProtoNumber(5) override val cfi: String?,
|
||||
@ProtoNumber(6) val svgWidth: String? = null,
|
||||
@ProtoNumber(7) val svgHeight: String? = null,
|
||||
@ProtoNumber(8) val svgViewBox: String? = null,
|
||||
@ProtoNumber(9) val isFromMathJax: Boolean = false,
|
||||
@ProtoNumber(10) override val blockIndex: Int,
|
||||
@ProtoNumber(11) override val expectedHeight: Int = 0
|
||||
) : ContentBlock
|
||||
|
||||
@Serializable
|
||||
data class WrappingContentBlock(
|
||||
@ProtoNumber(1) val floatedImage: ImageBlock,
|
||||
@ProtoNumber(2) val paragraphsToWrap: List<ParagraphBlock>,
|
||||
@ProtoNumber(3) override val style: BlockStyle = BlockStyle(),
|
||||
@ProtoNumber(4) override val elementId: String? = null,
|
||||
@ProtoNumber(5) override val cfi: String? = null,
|
||||
@ProtoNumber(6) override val blockIndex: Int,
|
||||
@ProtoNumber(7) override val expectedHeight: Int = 0
|
||||
) : ContentBlock
|
||||
|
||||
@Serializable
|
||||
data class TextEmphasis(
|
||||
@ProtoNumber(1) val style: String? = null,
|
||||
@ProtoNumber(2) val fill: String? = null,
|
||||
@ProtoNumber(3) @Serializable(with = ColorSerializer::class) val color: Color = Color.Unspecified,
|
||||
@ProtoNumber(4) val position: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CssStyle(
|
||||
@ProtoNumber(1) @Serializable(with = SpanStyleSerializer::class) val spanStyle: SpanStyle = SpanStyle(),
|
||||
@ProtoNumber(2) @Serializable(with = ParagraphStyleSerializer::class) val paragraphStyle: ParagraphStyle = ParagraphStyle(),
|
||||
@ProtoNumber(3) val blockStyle: BlockStyle = BlockStyle(),
|
||||
@ProtoNumber(4) val fontFamilies: List<String> = emptyList(),
|
||||
@ProtoNumber(5) val display: String? = null,
|
||||
@ProtoNumber(6) @Serializable(with = TextUnitSerializer::class) val fontSize: TextUnit = TextUnit.Unspecified,
|
||||
@ProtoNumber(7) val textTransform: String? = null,
|
||||
@ProtoNumber(8) val boxSizing: String? = null,
|
||||
@ProtoNumber(9) val content: String? = null,
|
||||
@ProtoNumber(10) val hyphens: String? = null,
|
||||
@ProtoNumber(11) val fontVariantNumeric: String? = null,
|
||||
@ProtoNumber(12) val textEmphasis: TextEmphasis? = null,
|
||||
@ProtoNumber(13) @Serializable(with = TextUnitSerializer::class) val wordSpacing: TextUnit = TextUnit.Unspecified,
|
||||
@ProtoNumber(14) val textDecorationStyle: String? = null,
|
||||
@ProtoNumber(15) @Serializable(with = ColorSerializer::class) val textDecorationColor: Color = Color.Unspecified,
|
||||
@ProtoNumber(16) @Serializable(with = DpSerializer::class) val textUnderlineOffset: Dp = Dp.Unspecified,
|
||||
@ProtoNumber(17) val whiteSpace: String? = null,
|
||||
@ProtoNumber(18) val verticalAlign: String? = null,
|
||||
@ProtoNumber(19) val customProperties: Map<String, String> = emptyMap()
|
||||
) {
|
||||
fun merge(other: CssStyle): CssStyle {
|
||||
return CssStyle(
|
||||
spanStyle = this.spanStyle.merge(other.spanStyle),
|
||||
paragraphStyle = this.paragraphStyle.merge(other.paragraphStyle),
|
||||
blockStyle = this.blockStyle.merge(other.blockStyle),
|
||||
fontFamilies = other.fontFamilies.takeIf { it.isNotEmpty() } ?: this.fontFamilies,
|
||||
display = other.display ?: this.display,
|
||||
fontSize = if (other.fontSize.isSpecified) other.fontSize else this.fontSize,
|
||||
textTransform = other.textTransform ?: this.textTransform,
|
||||
boxSizing = other.boxSizing ?: this.boxSizing,
|
||||
content = other.content ?: this.content,
|
||||
hyphens = other.hyphens ?: this.hyphens,
|
||||
fontVariantNumeric = other.fontVariantNumeric ?: this.fontVariantNumeric,
|
||||
textEmphasis = other.textEmphasis ?: this.textEmphasis,
|
||||
wordSpacing = if (other.wordSpacing.isSpecified) other.wordSpacing else this.wordSpacing,
|
||||
textDecorationStyle = other.textDecorationStyle ?: this.textDecorationStyle,
|
||||
textDecorationColor = if (other.textDecorationColor.isSpecified) other.textDecorationColor else this.textDecorationColor,
|
||||
textUnderlineOffset = if (other.textUnderlineOffset.isSpecified) other.textUnderlineOffset else this.textUnderlineOffset,
|
||||
whiteSpace = other.whiteSpace ?: this.whiteSpace,
|
||||
verticalAlign = other.verticalAlign ?: this.verticalAlign,
|
||||
customProperties = this.customProperties + other.customProperties
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class CssSelector(
|
||||
@ProtoNumber(1) val selector: String,
|
||||
@ProtoNumber(2) val specificity: Int
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CssRule(
|
||||
@ProtoNumber(1) val selector: CssSelector,
|
||||
@ProtoNumber(2) val style: CssStyle,
|
||||
@ProtoNumber(3) val pseudoElement: String? = null,
|
||||
@ProtoNumber(4) val sourceOrder: Int = 0
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FontFaceInfo(
|
||||
@ProtoNumber(1) val fontFamily: String,
|
||||
@ProtoNumber(2) val src: String,
|
||||
@ProtoNumber(3) @Serializable(with = org.dueattendant149.bookreader.paginatedreader.serialization.FontWeightSerializer::class) val fontWeight: FontWeight?,
|
||||
@ProtoNumber(4) @Serializable(with = org.dueattendant149.bookreader.paginatedreader.serialization.FontStyleSerializer::class) val fontStyle: FontStyle?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Page(
|
||||
@ProtoNumber(1) val content: List<ContentBlock>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FlexContainerBlock(
|
||||
@ProtoNumber(1) val children: List<ContentBlock>,
|
||||
@ProtoNumber(2) override val style: BlockStyle = BlockStyle(),
|
||||
@ProtoNumber(3) override val elementId: String? = null,
|
||||
@ProtoNumber(4) override val cfi: String? = null,
|
||||
@ProtoNumber(5) override val blockIndex: Int,
|
||||
@ProtoNumber(6) override val expectedHeight: Int = 0
|
||||
) : ContentBlock
|
||||
|
||||
@Serializable
|
||||
data class OptimizedCssRules(
|
||||
@ProtoNumber(1) val byTag: Map<String, List<CssRule>> = emptyMap(),
|
||||
@ProtoNumber(2) val byClass: Map<String, List<CssRule>> = emptyMap(),
|
||||
@ProtoNumber(3) val byId: Map<String, List<CssRule>> = emptyMap(),
|
||||
@ProtoNumber(4) val otherComplex: List<CssRule> = emptyList(),
|
||||
@ProtoNumber(5) val allRules: List<CssRule> = emptyList()
|
||||
) {
|
||||
fun merge(other: OptimizedCssRules): OptimizedCssRules {
|
||||
fun mergeMap(
|
||||
m1: Map<String, List<CssRule>>,
|
||||
m2: Map<String, List<CssRule>>
|
||||
): Map<String, List<CssRule>> {
|
||||
if (m1.isEmpty()) return m2
|
||||
if (m2.isEmpty()) return m1
|
||||
|
||||
val result = LinkedHashMap(m1)
|
||||
for ((key, value) in m2) {
|
||||
val existing = result[key]
|
||||
if (existing != null) {
|
||||
result[key] = existing + value
|
||||
} else {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return OptimizedCssRules(
|
||||
byTag = mergeMap(this.byTag, other.byTag),
|
||||
byClass = mergeMap(this.byClass, other.byClass),
|
||||
byId = mergeMap(this.byId, other.byId),
|
||||
otherComplex = this.otherComplex + other.otherComplex,
|
||||
allRules = this.toFlatList() + other.toFlatList()
|
||||
)
|
||||
}
|
||||
|
||||
fun toFlatList(): List<CssRule> {
|
||||
return allRules.ifEmpty { byTag.values.flatten() + byClass.values.flatten() + byId.values.flatten() + otherComplex }
|
||||
}
|
||||
}
|
||||
|
||||
data class OptimizedCssParseResult(
|
||||
val rules: OptimizedCssRules,
|
||||
val fontFaces: List<FontFaceInfo>
|
||||
)
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
/*
|
||||
* Episteme Reader - A native Android document reader.
|
||||
* Copyright (C) 2026 Episteme
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* mail: epistemereader@gmail.com
|
||||
*/
|
||||
@file:OptIn(ExperimentalSerializationApi::class)
|
||||
|
||||
package org.dueattendant149.bookreader.paginatedreader
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.protobuf.ProtoNumber
|
||||
import kotlinx.serialization.modules.SerializersModule
|
||||
import kotlinx.serialization.modules.polymorphic
|
||||
import kotlinx.serialization.modules.subclass
|
||||
|
||||
|
||||
@Serializable
|
||||
sealed interface SemanticBlock {
|
||||
val elementId: String?
|
||||
val cfi: String?
|
||||
val style: CssStyle
|
||||
val blockIndex: Int
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SemanticSpan(
|
||||
@ProtoNumber(1) val start: Int,
|
||||
@ProtoNumber(2) val end: Int,
|
||||
@ProtoNumber(3) val style: CssStyle,
|
||||
@ProtoNumber(4) val linkHref: String? = null,
|
||||
@ProtoNumber(5) val tag: String,
|
||||
@ProtoNumber(6) val elementId: String? = null // Add this
|
||||
)
|
||||
|
||||
fun SemanticBlock.withElementId(id: String): SemanticBlock {
|
||||
if (this.elementId != null) return this
|
||||
return when (this) {
|
||||
is SemanticParagraph -> this.copy(elementId = id)
|
||||
is SemanticHeader -> this.copy(elementId = id)
|
||||
is SemanticListItem -> this.copy(elementId = id)
|
||||
is SemanticList -> this.copy(elementId = id)
|
||||
is SemanticImage -> this.copy(elementId = id)
|
||||
is SemanticMath -> this.copy(elementId = id)
|
||||
is SemanticSpacer -> this.copy(elementId = id)
|
||||
is SemanticTable -> this.copy(elementId = id)
|
||||
is SemanticFlexContainer -> this.copy(elementId = id)
|
||||
is SemanticWrappingBlock -> this.copy(elementId = id)
|
||||
is SemanticTextBlock -> this
|
||||
}
|
||||
}
|
||||
|
||||
interface SemanticTextBlock : SemanticBlock {
|
||||
val text: String
|
||||
val spans: List<SemanticSpan>
|
||||
val startCharOffsetInSource: Int
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SemanticParagraph(
|
||||
@ProtoNumber(1) override val text: String,
|
||||
@ProtoNumber(2) override val spans: List<SemanticSpan>,
|
||||
@ProtoNumber(3) override val style: CssStyle,
|
||||
@ProtoNumber(4) override val elementId: String?,
|
||||
@ProtoNumber(5) override val cfi: String?,
|
||||
@ProtoNumber(6) override val startCharOffsetInSource: Int = 0,
|
||||
@ProtoNumber(7) override val blockIndex: Int = 0
|
||||
) : SemanticTextBlock
|
||||
|
||||
@Serializable
|
||||
data class SemanticHeader(
|
||||
@ProtoNumber(1) val level: Int,
|
||||
@ProtoNumber(2) override val text: String,
|
||||
@ProtoNumber(3) override val spans: List<SemanticSpan>,
|
||||
@ProtoNumber(4) override val style: CssStyle,
|
||||
@ProtoNumber(5) override val elementId: String?,
|
||||
@ProtoNumber(6) override val cfi: String?,
|
||||
@ProtoNumber(7) override val startCharOffsetInSource: Int = 0,
|
||||
@ProtoNumber(8) override val blockIndex: Int = 0
|
||||
) : SemanticTextBlock
|
||||
|
||||
@Serializable
|
||||
data class SemanticListItem(
|
||||
@ProtoNumber(1) override val text: String,
|
||||
@ProtoNumber(2) override val spans: List<SemanticSpan>,
|
||||
@ProtoNumber(3) override val style: CssStyle,
|
||||
@ProtoNumber(4) override val elementId: String?,
|
||||
@ProtoNumber(5) override val cfi: String?,
|
||||
@ProtoNumber(6) override val startCharOffsetInSource: Int = 0,
|
||||
@ProtoNumber(7) val itemMarkerImage: String?,
|
||||
@ProtoNumber(8) override val blockIndex: Int = 0
|
||||
) : SemanticTextBlock
|
||||
|
||||
@Serializable
|
||||
data class SemanticList(
|
||||
@ProtoNumber(1) val items: List<SemanticListItem>,
|
||||
@ProtoNumber(2) val isOrdered: Boolean,
|
||||
@ProtoNumber(3) override val style: CssStyle,
|
||||
@ProtoNumber(4) override val elementId: String?,
|
||||
@ProtoNumber(5) override val cfi: String?,
|
||||
@ProtoNumber(6) override val blockIndex: Int = 0
|
||||
) : SemanticBlock
|
||||
|
||||
@Serializable
|
||||
data class SemanticImage(
|
||||
@ProtoNumber(1) val path: String, // Will store the absolute path
|
||||
@ProtoNumber(2) val altText: String?,
|
||||
@ProtoNumber(3) val intrinsicWidth: Float?,
|
||||
@ProtoNumber(4) val intrinsicHeight: Float?,
|
||||
@ProtoNumber(5) override val style: CssStyle,
|
||||
@ProtoNumber(6) override val elementId: String?,
|
||||
@ProtoNumber(7) override val cfi: String?,
|
||||
@ProtoNumber(8) override val blockIndex: Int = 0
|
||||
) : SemanticBlock
|
||||
|
||||
@Serializable
|
||||
data class SemanticMath(
|
||||
@ProtoNumber(1) val svgContent: String?,
|
||||
@ProtoNumber(2) val altText: String?,
|
||||
@ProtoNumber(3) val svgWidth: String?,
|
||||
@ProtoNumber(4) val svgHeight: String?,
|
||||
@ProtoNumber(5) val svgViewBox: String?,
|
||||
@ProtoNumber(6) val isFromMathJax: Boolean,
|
||||
@ProtoNumber(7) override val style: CssStyle,
|
||||
@ProtoNumber(8) override val elementId: String?,
|
||||
@ProtoNumber(9) override val cfi: String?,
|
||||
@ProtoNumber(10) override val blockIndex: Int = 0
|
||||
) : SemanticBlock
|
||||
|
||||
@Serializable
|
||||
data class SemanticSpacer(
|
||||
@ProtoNumber(1) override val style: CssStyle,
|
||||
@ProtoNumber(2) override val elementId: String?,
|
||||
@ProtoNumber(3) override val cfi: String?,
|
||||
@ProtoNumber(4) val isExplicitLineBreak: Boolean = false,
|
||||
@ProtoNumber(5) override val blockIndex: Int = 0
|
||||
) : SemanticBlock
|
||||
|
||||
@Serializable
|
||||
data class SemanticTableCell(
|
||||
@ProtoNumber(1) val content: List<SemanticBlock>,
|
||||
@ProtoNumber(2) val isHeader: Boolean,
|
||||
@ProtoNumber(3) val colspan: Int,
|
||||
@ProtoNumber(4) val style: CssStyle
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SemanticTable(
|
||||
@ProtoNumber(1) val rows: List<List<SemanticTableCell>>,
|
||||
@ProtoNumber(2) override val style: CssStyle,
|
||||
@ProtoNumber(3) override val elementId: String?,
|
||||
@ProtoNumber(4) override val cfi: String?,
|
||||
@ProtoNumber(5) override val blockIndex: Int = 0
|
||||
) : SemanticBlock
|
||||
|
||||
@Serializable
|
||||
data class SemanticFlexContainer(
|
||||
@ProtoNumber(1) val children: List<SemanticBlock>,
|
||||
@ProtoNumber(2) override val style: CssStyle,
|
||||
@ProtoNumber(3) override val elementId: String?,
|
||||
@ProtoNumber(4) override val cfi: String?,
|
||||
@ProtoNumber(5) override val blockIndex: Int = 0
|
||||
) : SemanticBlock
|
||||
|
||||
@Serializable
|
||||
data class SemanticWrappingBlock(
|
||||
@ProtoNumber(1) val floatedImage: SemanticImage,
|
||||
@ProtoNumber(2) val paragraphsToWrap: List<SemanticParagraph>,
|
||||
@ProtoNumber(3) override val style: CssStyle,
|
||||
@ProtoNumber(4) override val elementId: String?,
|
||||
@ProtoNumber(5) override val cfi: String?,
|
||||
@ProtoNumber(6) override val blockIndex: Int = 0
|
||||
) : SemanticBlock
|
||||
|
||||
val semanticBlockModule = SerializersModule {
|
||||
polymorphic(SemanticBlock::class) {
|
||||
subclass(SemanticParagraph::class)
|
||||
subclass(SemanticHeader::class)
|
||||
subclass(SemanticListItem::class)
|
||||
subclass(SemanticList::class)
|
||||
subclass(SemanticImage::class)
|
||||
subclass(SemanticMath::class)
|
||||
subclass(SemanticSpacer::class)
|
||||
subclass(SemanticTable::class)
|
||||
subclass(SemanticFlexContainer::class)
|
||||
subclass(SemanticWrappingBlock::class)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
/*
|
||||
* Episteme Reader - A native Android document reader.
|
||||
* Copyright (C) 2026 Episteme
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* mail: epistemereader@gmail.com
|
||||
*/
|
||||
package org.dueattendant149.bookreader.paginatedreader
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
|
||||
fun parseCssDimensionToTextUnit(
|
||||
value: String,
|
||||
containerWidthPx: Int,
|
||||
density: Float
|
||||
): TextUnit {
|
||||
if (density <= 0) return TextUnit.Unspecified
|
||||
val sanitizedValue = value.trim().lowercase()
|
||||
return when {
|
||||
sanitizedValue.endsWith("rem") -> sanitizedValue.removeSuffix("rem").toFloatOrNull()?.em ?: TextUnit.Unspecified
|
||||
sanitizedValue.endsWith("em") -> sanitizedValue.removeSuffix("em").toFloatOrNull()?.em ?: TextUnit.Unspecified
|
||||
sanitizedValue.endsWith("px") -> {
|
||||
val px = sanitizedValue.removeSuffix("px").toFloatOrNull() ?: 0f
|
||||
(px / density).sp
|
||||
}
|
||||
sanitizedValue.endsWith("pt") -> {
|
||||
val pt = sanitizedValue.removeSuffix("pt").toFloatOrNull() ?: 0f
|
||||
val px = pt * (4f / 3f)
|
||||
(px / density).sp
|
||||
}
|
||||
sanitizedValue.endsWith("%") -> {
|
||||
val percentage = sanitizedValue.removeSuffix("%").toFloatOrNull() ?: 0f
|
||||
if (containerWidthPx > 0) {
|
||||
val px = (percentage / 100f) * containerWidthPx
|
||||
(px / density).sp
|
||||
} else {
|
||||
TextUnit.Unspecified
|
||||
}
|
||||
}
|
||||
else -> TextUnit.Unspecified
|
||||
}
|
||||
}
|
||||
|
||||
fun parseCssSizeToDp(
|
||||
value: String,
|
||||
baseFontSizeSp: Float,
|
||||
density: Float,
|
||||
containerWidthPx: Int
|
||||
): Dp {
|
||||
if (density <= 0) return 0.dp
|
||||
val sanitizedValue = value.trim().lowercase()
|
||||
|
||||
return when {
|
||||
sanitizedValue.endsWith("px") -> {
|
||||
val px = sanitizedValue.removeSuffix("px").toFloatOrNull() ?: 0f
|
||||
(px / density).dp
|
||||
}
|
||||
sanitizedValue.endsWith("rem") -> {
|
||||
val rem = sanitizedValue.removeSuffix("rem").toFloatOrNull() ?: 0f
|
||||
(rem * baseFontSizeSp).dp
|
||||
}
|
||||
sanitizedValue.endsWith("em") -> {
|
||||
val em = sanitizedValue.removeSuffix("em").toFloatOrNull() ?: 0f
|
||||
(em * baseFontSizeSp).dp
|
||||
}
|
||||
sanitizedValue.endsWith("pt") -> {
|
||||
val pt = sanitizedValue.removeSuffix("pt").toFloatOrNull() ?: 0f
|
||||
val px = pt * (4f / 3f)
|
||||
(px / density).dp
|
||||
}
|
||||
sanitizedValue.endsWith("%") -> {
|
||||
val percentage = sanitizedValue.removeSuffix("%").toFloatOrNull() ?: 0f
|
||||
if (containerWidthPx > 0) {
|
||||
val px = (percentage / 100f) * containerWidthPx
|
||||
(px / density).dp
|
||||
} else {
|
||||
0.dp
|
||||
}
|
||||
}
|
||||
else -> 0.dp
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
/*
|
||||
* Episteme Reader - A native Android document reader.
|
||||
* Copyright (C) 2026 Episteme
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* mail: epistemereader@gmail.com
|
||||
*/
|
||||
package org.dueattendant149.bookreader.paginatedreader
|
||||
|
||||
|
||||
object UserAgentStylesheet {
|
||||
val default: String = """
|
||||
/* Basic inline formatting */
|
||||
b, strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
i, em, cite, dfn {
|
||||
font-style: italic;
|
||||
}
|
||||
u {
|
||||
text-decoration: underline;
|
||||
}
|
||||
s, strike, del {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
code, kbd, samp, tt, pre {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Basic block elements */
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
margin-top: 0.67em;
|
||||
margin-bottom: 0.67em;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
margin-top: 0.83em;
|
||||
margin-bottom: 0.83em;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.17em;
|
||||
font-weight: bold;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
h4 {
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
margin-top: 1.33em;
|
||||
margin-bottom: 1.33em;
|
||||
}
|
||||
h5 {
|
||||
font-size: 0.83em;
|
||||
font-weight: bold;
|
||||
margin-top: 1.67em;
|
||||
margin-bottom: 1.67em;
|
||||
}
|
||||
h6 {
|
||||
font-size: 0.67em;
|
||||
font-weight: bold;
|
||||
margin-top: 2.33em;
|
||||
margin-bottom: 2.33em;
|
||||
}
|
||||
p {
|
||||
margin-top: 1em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
div {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
blockquote {
|
||||
margin-top: 1em;
|
||||
margin-bottom: 1em;
|
||||
margin-left: 40px;
|
||||
margin-right: 40px;
|
||||
}
|
||||
dl {
|
||||
margin-top: 1em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
dt {
|
||||
font-weight: bold;
|
||||
}
|
||||
dd {
|
||||
margin-left: 40px;
|
||||
}
|
||||
ul, ol {
|
||||
margin-top: 1em;
|
||||
margin-bottom: 1em;
|
||||
padding-left: 40px;
|
||||
}
|
||||
li {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
hr {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
|
@ -0,0 +1,513 @@
|
|||
/*
|
||||
* Episteme Reader - A native Android document reader.
|
||||
* Copyright (C) 2026 Episteme
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* mail: epistemereader@gmail.com
|
||||
*/
|
||||
@file:OptIn(ExperimentalSerializationApi::class)
|
||||
package org.dueattendant149.bookreader.paginatedreader.serialization
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shadow
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.ParagraphStyle
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.BaselineShift
|
||||
import androidx.compose.ui.text.style.Hyphens
|
||||
import androidx.compose.ui.text.style.LineBreak
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.text.style.TextIndent
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.TextUnitType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.isSpecified
|
||||
import org.dueattendant149.bookreader.paginatedreader.FontFamilyMapper
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.element
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.encoding.decodeStructure
|
||||
import kotlinx.serialization.encoding.encodeStructure
|
||||
|
||||
|
||||
object ColorSerializer : KSerializer<Color> {
|
||||
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Color") {
|
||||
element<Long>("value")
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: Color) {
|
||||
encoder.encodeStructure(descriptor) {
|
||||
encodeLongElement(descriptor, 0, value.value.toLong())
|
||||
}
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): Color {
|
||||
return decoder.decodeStructure(descriptor) {
|
||||
var colorValue = 0L
|
||||
while (true) {
|
||||
when (val index = decodeElementIndex(descriptor)) {
|
||||
0 -> colorValue = decodeLongElement(descriptor, 0)
|
||||
-1 -> break
|
||||
else -> error("Unexpected index: $index")
|
||||
}
|
||||
}
|
||||
Color(colorValue.toULong())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object DpSerializer : KSerializer<Dp> {
|
||||
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Dp") {
|
||||
element<Float>("value")
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: Dp) {
|
||||
encoder.encodeStructure(descriptor) {
|
||||
if (value != Dp.Unspecified) {
|
||||
encodeFloatElement(descriptor, 0, value.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): Dp {
|
||||
return decoder.decodeStructure(descriptor) {
|
||||
var dpValue: Float? = null
|
||||
while (true) {
|
||||
when (val index = decodeElementIndex(descriptor)) {
|
||||
0 -> dpValue = decodeFloatElement(descriptor, 0)
|
||||
-1 -> break
|
||||
else -> error("Unexpected index: $index")
|
||||
}
|
||||
}
|
||||
dpValue?.dp ?: Dp.Unspecified
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object TextUnitTypeSerializer : KSerializer<TextUnitType> {
|
||||
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("TextUnitType")
|
||||
override fun serialize(encoder: Encoder, value: TextUnitType) {
|
||||
val typeString = when (value) {
|
||||
TextUnitType.Sp -> "Sp"
|
||||
TextUnitType.Em -> "Em"
|
||||
else -> "Unspecified"
|
||||
}
|
||||
encoder.encodeString(typeString)
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): TextUnitType {
|
||||
return when (decoder.decodeString()) {
|
||||
"Sp" -> TextUnitType.Sp
|
||||
"Em" -> TextUnitType.Em
|
||||
else -> TextUnitType.Unspecified
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@SerialName("TextUnit")
|
||||
private data class TextUnitSurrogate(val value: Float, @Serializable(with = TextUnitTypeSerializer::class) val type: TextUnitType)
|
||||
|
||||
object TextUnitSerializer : KSerializer<TextUnit> {
|
||||
override val descriptor: SerialDescriptor = TextUnitSurrogate.serializer().descriptor
|
||||
|
||||
override fun serialize(encoder: Encoder, value: TextUnit) {
|
||||
if (value.isSpecified) {
|
||||
val surrogate = TextUnitSurrogate(value.value, value.type)
|
||||
encoder.encodeSerializableValue(TextUnitSurrogate.serializer(), surrogate)
|
||||
}
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): TextUnit {
|
||||
return try {
|
||||
val surrogate = decoder.decodeSerializableValue(TextUnitSurrogate.serializer())
|
||||
TextUnit(surrogate.value, surrogate.type)
|
||||
} catch (_: Exception) {
|
||||
TextUnit.Unspecified
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object FontWeightSerializer : KSerializer<FontWeight?> {
|
||||
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("FontWeight")
|
||||
override fun serialize(encoder: Encoder, value: FontWeight?) = value?.let { encoder.encodeInt(it.weight) } ?: encoder.encodeNull()
|
||||
override fun deserialize(decoder: Decoder): FontWeight? = if (decoder.decodeNotNullMark()) FontWeight(decoder.decodeInt()) else null
|
||||
}
|
||||
|
||||
object FontStyleSerializer : KSerializer<FontStyle?> {
|
||||
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("FontStyle")
|
||||
override fun serialize(encoder: Encoder, value: FontStyle?) {
|
||||
val intValue = when (value) {
|
||||
FontStyle.Normal -> 0
|
||||
FontStyle.Italic -> 1
|
||||
else -> -1
|
||||
}
|
||||
encoder.encodeInt(intValue)
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): FontStyle? {
|
||||
return when (decoder.decodeInt()) {
|
||||
0 -> FontStyle.Normal
|
||||
1 -> FontStyle.Italic
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object BaselineShiftSerializer : KSerializer<BaselineShift?> {
|
||||
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("BaselineShift")
|
||||
override fun serialize(encoder: Encoder, value: BaselineShift?) = value?.let { encoder.encodeFloat(it.multiplier) } ?: encoder.encodeNull()
|
||||
override fun deserialize(decoder: Decoder): BaselineShift? = if (decoder.decodeNotNullMark()) BaselineShift(decoder.decodeFloat()) else null
|
||||
}
|
||||
|
||||
object TextDecorationSerializer : KSerializer<TextDecoration?> {
|
||||
@Serializable
|
||||
private data class TextDecorationSurrogate(val hasUnderline: Boolean, val hasLineThrough: Boolean)
|
||||
|
||||
override val descriptor: SerialDescriptor = TextDecorationSurrogate.serializer().descriptor
|
||||
|
||||
override fun serialize(encoder: Encoder, value: TextDecoration?) {
|
||||
if (value == null) {
|
||||
encoder.encodeNull()
|
||||
return
|
||||
}
|
||||
val surrogate = TextDecorationSurrogate(
|
||||
hasUnderline = value.contains(TextDecoration.Underline),
|
||||
hasLineThrough = value.contains(TextDecoration.LineThrough)
|
||||
)
|
||||
encoder.encodeSerializableValue(TextDecorationSurrogate.serializer(), surrogate)
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): TextDecoration? {
|
||||
if (decoder.decodeNotNullMark()) {
|
||||
val surrogate = decoder.decodeSerializableValue(TextDecorationSurrogate.serializer())
|
||||
var decoration: TextDecoration? = null
|
||||
if (surrogate.hasUnderline) {
|
||||
decoration = TextDecoration.Underline
|
||||
}
|
||||
if (surrogate.hasLineThrough) {
|
||||
decoration = (decoration ?: TextDecoration.None) + TextDecoration.LineThrough
|
||||
}
|
||||
return decoration
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class ShadowSurrogate(
|
||||
@Serializable(with = ColorSerializer::class) val color: Color,
|
||||
val offsetX: Float,
|
||||
val offsetY: Float,
|
||||
val blurRadius: Float
|
||||
)
|
||||
|
||||
object ShadowSerializer : KSerializer<Shadow?> {
|
||||
override val descriptor: SerialDescriptor = ShadowSurrogate.serializer().descriptor
|
||||
|
||||
override fun serialize(encoder: Encoder, value: Shadow?) {
|
||||
if (value == null) {
|
||||
encoder.encodeNull()
|
||||
return
|
||||
}
|
||||
val surrogate = ShadowSurrogate(value.color, value.offset.x, value.offset.y, value.blurRadius)
|
||||
encoder.encodeSerializableValue(ShadowSurrogate.serializer(), surrogate)
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): Shadow? {
|
||||
if (decoder.decodeNotNullMark()) {
|
||||
val surrogate = decoder.decodeSerializableValue(ShadowSurrogate.serializer())
|
||||
return Shadow(surrogate.color, Offset(surrogate.offsetX, surrogate.offsetY), surrogate.blurRadius)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class SpanStyleSurrogate(
|
||||
@Serializable(with = ColorSerializer::class) val color: Color = Color.Unspecified,
|
||||
@Serializable(with = TextUnitSerializer::class) val fontSize: TextUnit = TextUnit.Unspecified,
|
||||
@Serializable(with = FontWeightSerializer::class) val fontWeight: FontWeight? = null,
|
||||
@Serializable(with = FontStyleSerializer::class) val fontStyle: FontStyle? = null,
|
||||
@Serializable(with = FontFamilySerializer::class) val fontFamily: FontFamily? = null,
|
||||
val fontFeatureSettings: String? = null,
|
||||
@Serializable(with = TextUnitSerializer::class) val letterSpacing: TextUnit = TextUnit.Unspecified,
|
||||
@Serializable(with = BaselineShiftSerializer::class) val baselineShift: BaselineShift? = null,
|
||||
@Serializable(with = TextDecorationSerializer::class) val textDecoration: TextDecoration? = null,
|
||||
@Serializable(with = ColorSerializer::class) val background: Color = Color.Unspecified,
|
||||
@Serializable(with = ShadowSerializer::class) val shadow: Shadow? = null
|
||||
)
|
||||
|
||||
object SpanStyleSerializer : KSerializer<SpanStyle> {
|
||||
override val descriptor: SerialDescriptor = SpanStyleSurrogate.serializer().descriptor
|
||||
|
||||
override fun serialize(encoder: Encoder, value: SpanStyle) {
|
||||
val surrogate = SpanStyleSurrogate(
|
||||
color = value.color,
|
||||
fontSize = value.fontSize,
|
||||
fontWeight = value.fontWeight,
|
||||
fontStyle = value.fontStyle,
|
||||
fontFamily = value.fontFamily,
|
||||
fontFeatureSettings = value.fontFeatureSettings,
|
||||
letterSpacing = value.letterSpacing,
|
||||
baselineShift = value.baselineShift,
|
||||
textDecoration = value.textDecoration,
|
||||
background = value.background,
|
||||
shadow = value.shadow
|
||||
)
|
||||
encoder.encodeSerializableValue(SpanStyleSurrogate.serializer(), surrogate)
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): SpanStyle {
|
||||
val surrogate = decoder.decodeSerializableValue(SpanStyleSurrogate.serializer())
|
||||
return SpanStyle(
|
||||
color = surrogate.color,
|
||||
fontSize = surrogate.fontSize,
|
||||
fontWeight = surrogate.fontWeight,
|
||||
fontStyle = surrogate.fontStyle,
|
||||
fontFamily = surrogate.fontFamily,
|
||||
fontFeatureSettings = surrogate.fontFeatureSettings,
|
||||
letterSpacing = surrogate.letterSpacing,
|
||||
baselineShift = surrogate.baselineShift,
|
||||
textDecoration = surrogate.textDecoration,
|
||||
background = surrogate.background,
|
||||
shadow = surrogate.shadow
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object TextAlignSerializer : KSerializer<TextAlign?> {
|
||||
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("TextAlign")
|
||||
override fun serialize(encoder: Encoder, value: TextAlign?) {
|
||||
val intValue = when(value) {
|
||||
TextAlign.Left -> 1
|
||||
TextAlign.Right -> 2
|
||||
TextAlign.Center -> 3
|
||||
TextAlign.Justify -> 4
|
||||
TextAlign.Start -> 5
|
||||
TextAlign.End -> 6
|
||||
else -> 0 // null or unspecified
|
||||
}
|
||||
encoder.encodeInt(intValue)
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): TextAlign? {
|
||||
return when(decoder.decodeInt()) {
|
||||
1 -> TextAlign.Left
|
||||
2 -> TextAlign.Right
|
||||
3 -> TextAlign.Center
|
||||
4 -> TextAlign.Justify
|
||||
5 -> TextAlign.Start
|
||||
6 -> TextAlign.End
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object TextDirectionSerializer : KSerializer<TextDirection?> {
|
||||
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("TextDirection")
|
||||
override fun serialize(encoder: Encoder, value: TextDirection?) {
|
||||
val intValue = when(value) {
|
||||
TextDirection.Ltr -> 1
|
||||
TextDirection.Rtl -> 2
|
||||
TextDirection.Content -> 3
|
||||
TextDirection.ContentOrLtr -> 4
|
||||
TextDirection.ContentOrRtl -> 5
|
||||
else -> 0 // null
|
||||
}
|
||||
encoder.encodeInt(intValue)
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): TextDirection? {
|
||||
return when(decoder.decodeInt()) {
|
||||
1 -> TextDirection.Ltr
|
||||
2 -> TextDirection.Rtl
|
||||
3 -> TextDirection.Content
|
||||
4 -> TextDirection.ContentOrLtr
|
||||
5 -> TextDirection.ContentOrRtl
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object LineBreakSerializer : KSerializer<LineBreak?> {
|
||||
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("LineBreak")
|
||||
override fun serialize(encoder: Encoder, value: LineBreak?) {
|
||||
val intValue = when (value) {
|
||||
LineBreak.Simple -> 1
|
||||
LineBreak.Paragraph -> 2
|
||||
else -> 0
|
||||
}
|
||||
encoder.encodeInt(intValue)
|
||||
}
|
||||
override fun deserialize(decoder: Decoder): LineBreak? {
|
||||
return when(decoder.decodeInt()) {
|
||||
1 -> LineBreak.Simple
|
||||
2 -> LineBreak.Paragraph
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object HyphensSerializer : KSerializer<Hyphens?> {
|
||||
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Hyphens")
|
||||
override fun serialize(encoder: Encoder, value: Hyphens?) {
|
||||
val intValue = when(value) {
|
||||
Hyphens.None -> 1
|
||||
Hyphens.Auto -> 2
|
||||
else -> 0 // null or unspecified
|
||||
}
|
||||
encoder.encodeInt(intValue)
|
||||
}
|
||||
override fun deserialize(decoder: Decoder): Hyphens? {
|
||||
return when(decoder.decodeInt()) {
|
||||
1 -> Hyphens.None
|
||||
2 -> Hyphens.Auto
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class TextIndentSurrogate(
|
||||
@Serializable(with = TextUnitSerializer::class) val firstLine: TextUnit,
|
||||
@Serializable(with = TextUnitSerializer::class) val restLine: TextUnit
|
||||
)
|
||||
|
||||
object TextIndentSerializer : KSerializer<TextIndent?> {
|
||||
override val descriptor: SerialDescriptor = TextIndentSurrogate.serializer().descriptor
|
||||
|
||||
override fun serialize(encoder: Encoder, value: TextIndent?) {
|
||||
if (value == null) {
|
||||
encoder.encodeNull()
|
||||
} else {
|
||||
encoder.encodeSerializableValue(TextIndentSurrogate.serializer(), TextIndentSurrogate(value.firstLine, value.restLine))
|
||||
}
|
||||
}
|
||||
override fun deserialize(decoder: Decoder): TextIndent? {
|
||||
return if (decoder.decodeNotNullMark()) {
|
||||
val surrogate = decoder.decodeSerializableValue(TextIndentSurrogate.serializer())
|
||||
TextIndent(surrogate.firstLine, surrogate.restLine)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class ParagraphStyleSurrogate(
|
||||
@Serializable(with = TextAlignSerializer::class) val textAlign: TextAlign? = null,
|
||||
@Serializable(with = TextDirectionSerializer::class) val textDirection: TextDirection? = null,
|
||||
@Serializable(with = TextUnitSerializer::class) val lineHeight: TextUnit = TextUnit.Unspecified,
|
||||
@Serializable(with = TextIndentSerializer::class) val textIndent: TextIndent? = null,
|
||||
@Serializable(with = LineBreakSerializer::class) val lineBreak: LineBreak? = null,
|
||||
@Serializable(with = HyphensSerializer::class) val hyphens: Hyphens? = null
|
||||
)
|
||||
|
||||
object ParagraphStyleSerializer : KSerializer<ParagraphStyle> {
|
||||
override val descriptor: SerialDescriptor = ParagraphStyleSurrogate.serializer().descriptor
|
||||
override fun serialize(encoder: Encoder, value: ParagraphStyle) {
|
||||
val surrogate = ParagraphStyleSurrogate(
|
||||
textAlign = value.textAlign,
|
||||
textDirection = value.textDirection,
|
||||
lineHeight = value.lineHeight,
|
||||
textIndent = value.textIndent,
|
||||
lineBreak = value.lineBreak,
|
||||
hyphens = value.hyphens
|
||||
)
|
||||
encoder.encodeSerializableValue(ParagraphStyleSurrogate.serializer(), surrogate)
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): ParagraphStyle {
|
||||
val surrogate = decoder.decodeSerializableValue(ParagraphStyleSurrogate.serializer())
|
||||
return ParagraphStyle(
|
||||
textAlign = surrogate.textAlign ?: TextAlign.Unspecified,
|
||||
textDirection = surrogate.textDirection ?: TextDirection.Unspecified,
|
||||
lineHeight = surrogate.lineHeight,
|
||||
textIndent = surrogate.textIndent,
|
||||
lineBreak = surrogate.lineBreak ?: LineBreak.Unspecified,
|
||||
hyphens = surrogate.hyphens ?: Hyphens.Unspecified
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object AnnotatedStringSerializer : KSerializer<AnnotatedString> {
|
||||
@Serializable
|
||||
private data class RangeSurrogate<T>(val item: T, val start: Int, val end: Int, val tag: String)
|
||||
|
||||
@Serializable
|
||||
private data class AnnotatedStringSurrogate(
|
||||
val text: String,
|
||||
val spanStyles: List<RangeSurrogate<@Serializable(with = SpanStyleSerializer::class) SpanStyle>>,
|
||||
val paragraphStyles: List<RangeSurrogate<@Serializable(with = ParagraphStyleSerializer::class) ParagraphStyle>>,
|
||||
val stringAnnotations: List<RangeSurrogate<String>>
|
||||
)
|
||||
|
||||
override val descriptor: SerialDescriptor = AnnotatedStringSurrogate.serializer().descriptor
|
||||
|
||||
override fun serialize(encoder: Encoder, value: AnnotatedString) {
|
||||
val surrogate = AnnotatedStringSurrogate(
|
||||
text = value.text,
|
||||
spanStyles = value.spanStyles.map { RangeSurrogate(it.item, it.start, it.end, it.tag) },
|
||||
paragraphStyles = value.paragraphStyles.map { RangeSurrogate(it.item, it.start, it.end, it.tag) },
|
||||
stringAnnotations = value.getStringAnnotations(0, value.length).map { RangeSurrogate(it.item, it.start, it.end, it.tag) }
|
||||
)
|
||||
encoder.encodeSerializableValue(AnnotatedStringSurrogate.serializer(), surrogate)
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): AnnotatedString {
|
||||
val surrogate = decoder.decodeSerializableValue(AnnotatedStringSurrogate.serializer())
|
||||
return AnnotatedString.Builder(surrogate.text).apply {
|
||||
surrogate.spanStyles.forEach { addStyle(it.item, it.start, it.end) }
|
||||
surrogate.paragraphStyles.forEach { addStyle(it.item, it.start, it.end) }
|
||||
surrogate.stringAnnotations.forEach { addStringAnnotation(it.tag, it.item, it.start, it.end) }
|
||||
}.toAnnotatedString()
|
||||
}
|
||||
}
|
||||
|
||||
object FontFamilySerializer : KSerializer<FontFamily?> {
|
||||
override val descriptor = PrimitiveSerialDescriptor("FontFamily", PrimitiveKind.STRING)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: FontFamily?) {
|
||||
val name = FontFamilyMapper.fontFamilyToName(value ?: return encoder.encodeNull())
|
||||
if (name != null) {
|
||||
encoder.encodeString(name)
|
||||
} else {
|
||||
encoder.encodeNull()
|
||||
}
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): FontFamily? {
|
||||
if (decoder.decodeNotNullMark()) {
|
||||
return FontFamilyMapper.nameToFontFamily(decoder.decodeString())
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import org.dueattendant149.bookreader.shared.pdf.SharedPdfHighlighterPalette
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderSettings
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderSearchOptions
|
||||
|
||||
sealed interface LibraryAction {
|
||||
data class SearchChanged(val query: String) : LibraryAction
|
||||
data class SortChanged(val sortOrder: SortOrder) : LibraryAction
|
||||
data class FiltersChanged(val filters: LibraryFilters) : LibraryAction
|
||||
data class BookSelectionToggled(val bookId: String) : LibraryAction
|
||||
data class BookSelectionReplaced(val bookIds: Set<String>) : LibraryAction
|
||||
data object SelectionCleared : LibraryAction
|
||||
data class ShelfSelectionToggled(val shelfId: String) : LibraryAction
|
||||
data object ShelfSelectionCleared : LibraryAction
|
||||
data class LibraryPageChanged(val page: Int) : LibraryAction
|
||||
data class RecentLimitChanged(val limit: Int) : LibraryAction
|
||||
}
|
||||
|
||||
sealed interface ReaderAction {
|
||||
data object NextPage : ReaderAction
|
||||
data object PreviousPage : ReaderAction
|
||||
data class GoToPage(val pageIndex: Int) : ReaderAction
|
||||
data class GoToPageNumber(val pageNumber: Int) : ReaderAction
|
||||
data class GoToProgress(val progress: Float) : ReaderAction
|
||||
data class GoToChapter(val chapterIndex: Int) : ReaderAction
|
||||
data class GoToLocator(val locator: ReaderLocator) : ReaderAction
|
||||
data class JumpToPage(val pageIndex: Int) : ReaderAction
|
||||
data class JumpToPageNumber(val pageNumber: Int) : ReaderAction
|
||||
data class JumpToChapter(val chapterIndex: Int) : ReaderAction
|
||||
data class JumpToLocator(val locator: ReaderLocator) : ReaderAction
|
||||
data class VisiblePageChanged(val pageIndex: Int, val locator: ReaderLocator? = null) : ReaderAction
|
||||
data class GoToSearchResult(val resultIndex: Int) : ReaderAction
|
||||
data class JumpToSearchResult(val resultIndex: Int) : ReaderAction
|
||||
data object JumpToNextSearchResult : ReaderAction
|
||||
data object JumpToPreviousSearchResult : ReaderAction
|
||||
data object JumpBack : ReaderAction
|
||||
data object JumpForward : ReaderAction
|
||||
data object JumpHistoryCleared : ReaderAction
|
||||
data class SearchChanged(val query: String) : ReaderAction
|
||||
data object SearchOpened : ReaderAction
|
||||
data object SearchClosed : ReaderAction
|
||||
data object SearchResultsPanelToggled : ReaderAction
|
||||
data class SearchOptionsChanged(val options: ReaderSearchOptions) : ReaderAction
|
||||
data object NextSearchResult : ReaderAction
|
||||
data object PreviousSearchResult : ReaderAction
|
||||
data object ToggleBookmark : ReaderAction
|
||||
data class ToggleBookmarkAtLocator(
|
||||
val locator: ReaderLocator,
|
||||
val title: String? = null,
|
||||
val preview: String? = null
|
||||
) : ReaderAction
|
||||
data class SettingsChanged(val settings: ReaderSettings) : ReaderAction
|
||||
data class RenderModeChanged(val renderMode: RenderMode) : ReaderAction
|
||||
data class ThemeChanged(val theme: ReaderTheme) : ReaderAction
|
||||
data class FormatChanged(val settings: FormatSettings) : ReaderAction
|
||||
data class HighlightCreated(val highlight: UserHighlight) : ReaderAction
|
||||
data class HighlightUpdated(
|
||||
val highlightId: String,
|
||||
val color: HighlightColor? = null,
|
||||
val note: String? = null
|
||||
) : ReaderAction
|
||||
data class HighlightDeleted(val highlightId: String) : ReaderAction
|
||||
}
|
||||
|
||||
sealed interface AppAction {
|
||||
data class BannerShown(val message: BannerMessage) : AppAction
|
||||
data object BannerDismissed : AppAction
|
||||
data class NavigationRequested(val event: NavigationEvent) : AppAction
|
||||
data class AppThemeChanged(val mode: AppThemeMode) : AppAction
|
||||
data class AppContrastChanged(val option: AppContrastOption) : 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 CustomReaderThemesChanged(val themes: List<ReaderTheme>) : AppAction
|
||||
data class SyncEnabledChanged(val enabled: Boolean) : AppAction
|
||||
data class FolderSyncEnabledChanged(val enabled: Boolean) : AppAction
|
||||
data class TabsEnabledChanged(val enabled: Boolean) : AppAction
|
||||
data class BookTabOpened(val bookId: String) : AppAction
|
||||
data class BookTabClosed(val bookId: String) : AppAction
|
||||
data object AllTabsClosed : AppAction
|
||||
data class HomePinToggled(val bookId: String) : AppAction
|
||||
data class LibraryPinToggled(val bookId: String) : AppAction
|
||||
data class ReaderDefaultSettingsChanged(val settings: ReaderSettings) : AppAction
|
||||
data class PdfReaderDefaultSettingsChanged(val settings: ReaderSettings) : AppAction
|
||||
data class ReaderToolbarPreferencesChanged(val preferences: ReaderToolbarPreferences) : AppAction
|
||||
data class ReaderToolVisibilityChanged(val tool: ReaderTool, val hidden: Boolean) : AppAction
|
||||
data class ReaderToolPlacementChanged(val tool: ReaderTool, val bottom: Boolean) : AppAction
|
||||
data class ReaderToolOrderChanged(val toolOrder: List<ReaderTool>) : AppAction
|
||||
data class ReaderHighlightPaletteChanged(val palette: ReaderHighlightPalette) : AppAction
|
||||
data class PdfHighlighterPaletteChanged(val palette: SharedPdfHighlighterPalette) : AppAction
|
||||
data class ReaderTtsReplacementPreferencesChanged(
|
||||
val preferences: ReaderTtsReplacementPreferences,
|
||||
) : AppAction
|
||||
}
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import org.dueattendant149.bookreader.shared.pdf.SharedPdfHighlighterPalette
|
||||
import org.dueattendant149.bookreader.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 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,
|
||||
val bookId: String,
|
||||
val type: FileType,
|
||||
val bundleId: String? = null
|
||||
)
|
||||
|
||||
data class UserData(
|
||||
val uid: String,
|
||||
val displayName: String?,
|
||||
val photoUrl: String?,
|
||||
val email: String?
|
||||
)
|
||||
|
||||
data class NavigationEvent(
|
||||
val route: String,
|
||||
val bookId: String? = null,
|
||||
val uriString: String? = null
|
||||
)
|
||||
|
||||
enum class AppThemeMode {
|
||||
SYSTEM,
|
||||
LIGHT,
|
||||
DARK
|
||||
}
|
||||
|
||||
enum class AppContrastOption(val value: Double) {
|
||||
STANDARD(0.0),
|
||||
MEDIUM(0.5),
|
||||
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,
|
||||
val seedColor: Color
|
||||
)
|
||||
|
||||
data class DeviceItem(
|
||||
val deviceId: String,
|
||||
val deviceName: String,
|
||||
val lastSeenEpochMillis: Long?
|
||||
)
|
||||
|
||||
data class DeviceLimitReachedState(
|
||||
val isLimitReached: Boolean = false,
|
||||
val registeredDevices: List<DeviceItem> = emptyList()
|
||||
)
|
||||
|
||||
data class SharedReaderScreenState(
|
||||
val selectedBookId: String? = null,
|
||||
val selectedUriString: String? = null,
|
||||
val selectedFileType: FileType? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val renderMode: RenderMode = RenderMode.VERTICAL_SCROLL,
|
||||
val sortOrder: SortOrder = SortOrder.RECENT,
|
||||
val shelves: List<Shelf> = emptyList(),
|
||||
val viewingShelfId: String? = null,
|
||||
val isAddingBooksToShelf: Boolean = false,
|
||||
val showCreateShelfDialog: Boolean = false,
|
||||
val mainScreenStartPage: Int = 0,
|
||||
val libraryScreenStartPage: Int = 0,
|
||||
val showRenameShelfDialogFor: String? = null,
|
||||
val showDeleteShelfDialogFor: String? = null,
|
||||
val addBooksSource: AddBooksSource = AddBooksSource.UNSHELVED,
|
||||
val booksSelectedForAdding: Set<String> = emptySet(),
|
||||
val booksAvailableForAdding: List<BookItem> = emptyList(),
|
||||
val selectedBookIds: Set<String> = emptySet(),
|
||||
val selectedShelfIds: Set<String> = emptySet(),
|
||||
val currentUser: UserData? = null,
|
||||
val isAuthMenuExpanded: Boolean = false,
|
||||
val isProUser: Boolean = false,
|
||||
val credits: Int = 0,
|
||||
val isSyncEnabled: Boolean = false,
|
||||
val isFolderSyncEnabled: Boolean = false,
|
||||
val bannerMessage: BannerMessage? = null,
|
||||
val deviceLimitState: DeviceLimitReachedState = DeviceLimitReachedState(),
|
||||
val isReplacingDevice: Boolean = false,
|
||||
val isRequestingDrivePermission: Boolean = false,
|
||||
val downloadingBookIds: Set<String> = emptySet(),
|
||||
val uploadingBookIds: Set<String> = emptySet(),
|
||||
val syncedFolders: List<SyncedFolder> = emptyList(),
|
||||
val lastFolderScanTime: Long? = null,
|
||||
val hasUnreadFeedback: Boolean = false,
|
||||
val searchQuery: String = "",
|
||||
val isSearchActive: Boolean = false,
|
||||
val isRefreshing: Boolean = false,
|
||||
val reflowProgress: Float? = null,
|
||||
val recentBooks: List<BookItem> = emptyList(),
|
||||
val libraryBooks: List<BookItem> = emptyList(),
|
||||
val rawLibraryBooks: List<BookItem> = emptyList(),
|
||||
val pinnedHomeBookIds: Set<String> = emptySet(),
|
||||
val pinnedLibraryBookIds: Set<String> = emptySet(),
|
||||
val libraryFilters: LibraryFilters = LibraryFilters(),
|
||||
val recentFilesLimit: Int = 0,
|
||||
val isTabsEnabled: Boolean = true,
|
||||
val openTabIds: List<String> = emptyList(),
|
||||
val openTabs: List<BookItem> = emptyList(),
|
||||
val activeTabBookId: String? = null,
|
||||
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 customReaderThemes: List<ReaderTheme> = emptyList(),
|
||||
val readerDefaultSettings: ReaderSettings = ReaderSettings(),
|
||||
val pdfReaderDefaultSettings: ReaderSettings = ReaderSettings(themeId = "no_theme"),
|
||||
val allTags: List<Tag> = emptyList(),
|
||||
val showTagSelectionDialogFor: Set<String> = emptySet(),
|
||||
val readerToolbarPreferences: ReaderToolbarPreferences = ReaderToolbarPreferences(),
|
||||
val readerHighlightPalette: ReaderHighlightPalette = ReaderHighlightPalette(),
|
||||
val pdfHighlighterPalette: SharedPdfHighlighterPalette = SharedPdfHighlighterPalette(),
|
||||
val readerTtsReplacementPreferences: ReaderTtsReplacementPreferences = ReaderTtsReplacementPreferences()
|
||||
)
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
enum class SharedCloudBookMetadataWinner {
|
||||
LOCAL,
|
||||
REMOTE,
|
||||
SAME
|
||||
}
|
||||
|
||||
fun sharedCloudBookReadingMetadataWinner(
|
||||
localModifiedTimestamp: Long?,
|
||||
remoteModifiedTimestamp: Long
|
||||
): SharedCloudBookMetadataWinner {
|
||||
val localTimestamp = localModifiedTimestamp ?: Long.MIN_VALUE
|
||||
return when {
|
||||
localTimestamp > remoteModifiedTimestamp -> SharedCloudBookMetadataWinner.LOCAL
|
||||
remoteModifiedTimestamp > localTimestamp -> SharedCloudBookMetadataWinner.REMOTE
|
||||
else -> SharedCloudBookMetadataWinner.SAME
|
||||
}
|
||||
}
|
||||
|
||||
fun sharedCloudBookMetadataWinner(
|
||||
localModifiedTimestamp: Long?,
|
||||
remoteModifiedTimestamp: Long,
|
||||
localSidecarModifiedTimestamp: Long = 0L
|
||||
): SharedCloudBookMetadataWinner {
|
||||
val localTimestamp = maxOf(localModifiedTimestamp ?: Long.MIN_VALUE, localSidecarModifiedTimestamp)
|
||||
return when {
|
||||
localTimestamp > remoteModifiedTimestamp -> SharedCloudBookMetadataWinner.LOCAL
|
||||
remoteModifiedTimestamp > localTimestamp -> SharedCloudBookMetadataWinner.REMOTE
|
||||
else -> SharedCloudBookMetadataWinner.SAME
|
||||
}
|
||||
}
|
||||
|
||||
fun shouldApplyRemoteCloudBookMetadataUpdate(
|
||||
localModifiedTimestamp: Long?,
|
||||
remoteModifiedTimestamp: Long
|
||||
): Boolean {
|
||||
return sharedCloudBookReadingMetadataWinner(
|
||||
localModifiedTimestamp = localModifiedTimestamp,
|
||||
remoteModifiedTimestamp = remoteModifiedTimestamp
|
||||
) == SharedCloudBookMetadataWinner.REMOTE
|
||||
}
|
||||
|
||||
fun shouldUploadLocalCloudBookMetadataUpdate(
|
||||
localModifiedTimestamp: Long,
|
||||
remoteModifiedTimestamp: Long
|
||||
): Boolean {
|
||||
return sharedCloudBookReadingMetadataWinner(
|
||||
localModifiedTimestamp = localModifiedTimestamp,
|
||||
remoteModifiedTimestamp = remoteModifiedTimestamp
|
||||
) == SharedCloudBookMetadataWinner.LOCAL
|
||||
}
|
||||
|
||||
fun shouldApplyRemoteCloudBookUpdate(
|
||||
localModifiedTimestamp: Long?,
|
||||
remoteModifiedTimestamp: Long,
|
||||
localSidecarModifiedTimestamp: Long = 0L
|
||||
): Boolean {
|
||||
return sharedCloudBookMetadataWinner(
|
||||
localModifiedTimestamp = localModifiedTimestamp,
|
||||
remoteModifiedTimestamp = remoteModifiedTimestamp,
|
||||
localSidecarModifiedTimestamp = localSidecarModifiedTimestamp
|
||||
) == SharedCloudBookMetadataWinner.REMOTE
|
||||
}
|
||||
|
||||
fun shouldUploadLocalCloudBookUpdate(
|
||||
localModifiedTimestamp: Long,
|
||||
remoteModifiedTimestamp: Long,
|
||||
localSidecarModifiedTimestamp: Long = 0L
|
||||
): Boolean {
|
||||
return sharedCloudBookMetadataWinner(
|
||||
localModifiedTimestamp = localModifiedTimestamp,
|
||||
remoteModifiedTimestamp = remoteModifiedTimestamp,
|
||||
localSidecarModifiedTimestamp = localSidecarModifiedTimestamp
|
||||
) == SharedCloudBookMetadataWinner.LOCAL
|
||||
}
|
||||
|
||||
fun shouldDownloadRemoteCloudBookContent(
|
||||
localFileAvailable: Boolean,
|
||||
localContentModifiedTimestamp: Long,
|
||||
remoteContentModifiedTimestamp: Long,
|
||||
remoteDeleted: Boolean = false
|
||||
): Boolean {
|
||||
return !remoteDeleted &&
|
||||
remoteContentModifiedTimestamp > 0L &&
|
||||
(!localFileAvailable || remoteContentModifiedTimestamp > localContentModifiedTimestamp)
|
||||
}
|
||||
|
||||
fun shouldUploadLocalCloudBookContent(
|
||||
localFileAvailable: Boolean,
|
||||
localContentModifiedTimestamp: Long,
|
||||
remoteContentModifiedTimestamp: Long?
|
||||
): Boolean {
|
||||
return localFileAvailable &&
|
||||
localContentModifiedTimestamp > 0L &&
|
||||
localContentModifiedTimestamp > (remoteContentModifiedTimestamp ?: 0L)
|
||||
}
|
||||
|
||||
fun sharedCloudBookContentFileName(bookId: String, type: FileType): String? {
|
||||
val extension = SharedFileCapabilities.primaryExtensionFor(type) ?: return null
|
||||
return "$bookId.$extension"
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
||||
data class CustomFontItem(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val fileName: String,
|
||||
val fileExtension: String,
|
||||
val path: String,
|
||||
val timestamp: Long,
|
||||
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,417 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
enum class ReaderPlatform {
|
||||
ANDROID,
|
||||
DESKTOP
|
||||
}
|
||||
|
||||
enum class ReaderFeatureSurface {
|
||||
PDF_VIEWER,
|
||||
EPUB_READER,
|
||||
TEXT_READER
|
||||
}
|
||||
|
||||
data class FileTypeCapability(
|
||||
val type: FileType,
|
||||
val displayName: String,
|
||||
val extensions: Set<String>,
|
||||
val androidSurface: ReaderFeatureSurface?,
|
||||
val desktopSurface: ReaderFeatureSurface?,
|
||||
val syncEligible: Boolean = true
|
||||
) {
|
||||
val isReadableOnAndroid: Boolean get() = androidSurface != null
|
||||
val isReadableOnDesktop: Boolean get() = desktopSurface != null
|
||||
|
||||
fun surfaceFor(platform: ReaderPlatform): ReaderFeatureSurface? {
|
||||
return when (platform) {
|
||||
ReaderPlatform.ANDROID -> androidSurface
|
||||
ReaderPlatform.DESKTOP -> desktopSurface
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object SharedFileCapabilities {
|
||||
private val codeOrDataExtensions = setOf(
|
||||
"csv",
|
||||
"tsv",
|
||||
"json",
|
||||
"xml",
|
||||
"log",
|
||||
"java",
|
||||
"kt",
|
||||
"py",
|
||||
"js",
|
||||
"cpp",
|
||||
"c",
|
||||
"cs",
|
||||
"rb",
|
||||
"go"
|
||||
)
|
||||
|
||||
private val manualOnlyReaderMimeTypes = setOf(
|
||||
"text/csv",
|
||||
"text/comma-separated-values",
|
||||
"text/tab-separated-values",
|
||||
"application/json",
|
||||
"application/xml",
|
||||
"text/xml",
|
||||
"text/x-java-source",
|
||||
"text/x-python",
|
||||
"text/x-kotlin",
|
||||
"text/javascript",
|
||||
"application/javascript",
|
||||
"text/x-c",
|
||||
"text/x-c++",
|
||||
"text/x-csharp",
|
||||
"text/x-ruby",
|
||||
"text/x-go",
|
||||
"text/x-log"
|
||||
)
|
||||
|
||||
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.comicbook+tar",
|
||||
"application/x-cbt",
|
||||
"application/x-tar",
|
||||
"application/tar",
|
||||
"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,
|
||||
displayName = "EPUB",
|
||||
extensions = setOf("epub"),
|
||||
androidSurface = ReaderFeatureSurface.EPUB_READER,
|
||||
desktopSurface = ReaderFeatureSurface.EPUB_READER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.PDF,
|
||||
displayName = "PDF",
|
||||
extensions = setOf("pdf"),
|
||||
androidSurface = ReaderFeatureSurface.PDF_VIEWER,
|
||||
desktopSurface = ReaderFeatureSurface.PDF_VIEWER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.TXT,
|
||||
displayName = "TXT",
|
||||
extensions = setOf("txt"),
|
||||
androidSurface = ReaderFeatureSurface.EPUB_READER,
|
||||
desktopSurface = ReaderFeatureSurface.TEXT_READER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.MD,
|
||||
displayName = "Markdown",
|
||||
extensions = setOf("md", "markdown"),
|
||||
androidSurface = ReaderFeatureSurface.EPUB_READER,
|
||||
desktopSurface = ReaderFeatureSurface.TEXT_READER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.HTML,
|
||||
displayName = "HTML",
|
||||
extensions = setOf("html", "htm", "xhtml"),
|
||||
androidSurface = ReaderFeatureSurface.EPUB_READER,
|
||||
desktopSurface = ReaderFeatureSurface.TEXT_READER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.MOBI,
|
||||
displayName = "MOBI",
|
||||
extensions = setOf("mobi", "azw", "azw3", "prc"),
|
||||
androidSurface = ReaderFeatureSurface.EPUB_READER,
|
||||
desktopSurface = ReaderFeatureSurface.TEXT_READER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.FB2,
|
||||
displayName = "FB2",
|
||||
extensions = setOf("fb2"),
|
||||
androidSurface = ReaderFeatureSurface.EPUB_READER,
|
||||
desktopSurface = ReaderFeatureSurface.TEXT_READER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.CBZ,
|
||||
displayName = "CBZ",
|
||||
extensions = setOf("cbz"),
|
||||
androidSurface = ReaderFeatureSurface.PDF_VIEWER,
|
||||
desktopSurface = ReaderFeatureSurface.PDF_VIEWER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.CBR,
|
||||
displayName = "CBR",
|
||||
extensions = setOf("cbr"),
|
||||
androidSurface = ReaderFeatureSurface.PDF_VIEWER,
|
||||
desktopSurface = ReaderFeatureSurface.PDF_VIEWER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.CB7,
|
||||
displayName = "CB7",
|
||||
extensions = setOf("cb7"),
|
||||
androidSurface = ReaderFeatureSurface.PDF_VIEWER,
|
||||
desktopSurface = ReaderFeatureSurface.PDF_VIEWER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.CBT,
|
||||
displayName = "CBT",
|
||||
extensions = setOf("cbt"),
|
||||
androidSurface = ReaderFeatureSurface.PDF_VIEWER,
|
||||
desktopSurface = ReaderFeatureSurface.PDF_VIEWER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.DOCX,
|
||||
displayName = "DOCX",
|
||||
extensions = setOf("docx"),
|
||||
androidSurface = ReaderFeatureSurface.EPUB_READER,
|
||||
desktopSurface = ReaderFeatureSurface.TEXT_READER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.ODT,
|
||||
displayName = "ODT",
|
||||
extensions = setOf("odt"),
|
||||
androidSurface = ReaderFeatureSurface.EPUB_READER,
|
||||
desktopSurface = ReaderFeatureSurface.TEXT_READER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.FODT,
|
||||
displayName = "FODT",
|
||||
extensions = setOf("fodt"),
|
||||
androidSurface = ReaderFeatureSurface.EPUB_READER,
|
||||
desktopSurface = ReaderFeatureSurface.TEXT_READER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.PPTX,
|
||||
displayName = "PPTX",
|
||||
extensions = setOf("pptx"),
|
||||
androidSurface = ReaderFeatureSurface.PDF_VIEWER,
|
||||
desktopSurface = ReaderFeatureSurface.PDF_VIEWER
|
||||
)
|
||||
)
|
||||
|
||||
private val capabilitiesByType: Map<FileType, FileTypeCapability> = all.associateBy { it.type }
|
||||
private val typesByExtension: Map<String, FileType> = all
|
||||
.flatMap { capability -> capability.extensions.map { it.lowercase() to capability.type } }
|
||||
.toMap()
|
||||
private val mimeTypesByType: Map<FileType, String> = mapOf(
|
||||
FileType.PDF to "application/pdf",
|
||||
FileType.EPUB to "application/epub+zip",
|
||||
FileType.MOBI to "application/x-mobipocket-ebook",
|
||||
FileType.MD to "text/markdown",
|
||||
FileType.TXT to "text/plain",
|
||||
FileType.HTML to "text/html",
|
||||
FileType.FB2 to "application/x-fictionbook+xml",
|
||||
FileType.CBZ to "application/zip",
|
||||
FileType.CBR to "application/zip",
|
||||
FileType.CB7 to "application/zip",
|
||||
FileType.CBT to "application/x-tar",
|
||||
FileType.DOCX to "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
FileType.PPTX to "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
FileType.ODT to "application/vnd.oasis.opendocument.text",
|
||||
FileType.FODT to "application/x-vnd.oasis.opendocument.text-flat-xml"
|
||||
)
|
||||
val comicArchiveTypes: Set<FileType> = setOf(FileType.CBZ, FileType.CBR, FileType.CB7, FileType.CBT)
|
||||
val knownFileTypes: Set<FileType> = all.mapTo(mutableSetOf()) { it.type }
|
||||
|
||||
fun capabilityFor(type: FileType): FileTypeCapability? {
|
||||
return capabilitiesByType[type]
|
||||
}
|
||||
|
||||
fun displayNameFor(type: FileType): String {
|
||||
return capabilityFor(type)?.displayName ?: type.name
|
||||
}
|
||||
|
||||
fun primaryExtensionFor(type: FileType): String? {
|
||||
return capabilityFor(type)?.extensions?.firstOrNull()
|
||||
}
|
||||
|
||||
fun mimeTypeFor(type: FileType): String? {
|
||||
return mimeTypesByType[type]
|
||||
}
|
||||
|
||||
fun isComicArchive(type: FileType): Boolean {
|
||||
return type in comicArchiveTypes
|
||||
}
|
||||
|
||||
fun fileTypeForName(fileName: String): FileType {
|
||||
return resolveFileTypeForName(fileName) ?: FileType.UNKNOWN
|
||||
}
|
||||
|
||||
fun resolveFileTypeForName(fileName: String?): FileType? {
|
||||
val normalized = fileName?.normalizedFileName()?.takeIf { it.isNotBlank() } ?: return null
|
||||
val effectiveName = normalized.withTransparentTextSuffix()
|
||||
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/vnd.comicbook+tar", "application/x-cbt", "application/x-tar", "application/tar" -> {
|
||||
if (fileName?.endsWith(".cbt", ignoreCase = true) == true) FileType.CBT 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()
|
||||
.extensionAfterLastDot() in codeOrDataExtensions
|
||||
}
|
||||
|
||||
fun isManualOnlyReaderFileName(fileName: String?): Boolean {
|
||||
return fileName?.let(::isCodeOrDataFileName) ?: false
|
||||
}
|
||||
|
||||
fun isManualOnlyReaderMimeType(mimeType: String?): Boolean {
|
||||
val normalized = mimeType
|
||||
?.substringBefore(';')
|
||||
?.trim()
|
||||
?.lowercase()
|
||||
?: return false
|
||||
return normalized in manualOnlyReaderMimeTypes
|
||||
}
|
||||
|
||||
fun isLocalFolderSyncEligibleFile(name: String, mimeType: String?): Boolean {
|
||||
if (isManualOnlyReaderFileName(name)) return false
|
||||
if (resolveFileTypeForName(name) != null) return true
|
||||
return !isManualOnlyReaderMimeType(mimeType)
|
||||
}
|
||||
|
||||
fun fileExtensionSuffixForName(fileName: String?): String? {
|
||||
val normalized = fileName?.normalizedFileName()?.takeIf { it.isNotBlank() } ?: return null
|
||||
val effectiveName = normalized.withTransparentTextSuffix()
|
||||
val effectiveSuffix = when {
|
||||
effectiveName.endsWith(".fb2.zip") -> ".fb2.zip"
|
||||
effectiveName.endsWith(".markdown") -> ".markdown"
|
||||
effectiveName.endsWith(".xhtml") -> ".xhtml"
|
||||
effectiveName.extensionAfterLastDot() != null && resolveFileTypeForName(effectiveName) != null -> {
|
||||
".${effectiveName.extensionAfterLastDot()}"
|
||||
}
|
||||
else -> null
|
||||
} ?: return null
|
||||
|
||||
return if (effectiveName != normalized && normalized.endsWith(".txt")) {
|
||||
"$effectiveSuffix.txt"
|
||||
} else {
|
||||
effectiveSuffix
|
||||
}
|
||||
}
|
||||
|
||||
fun surfaceFor(type: FileType, platform: ReaderPlatform): ReaderFeatureSurface? {
|
||||
return capabilityFor(type)?.surfaceFor(platform)
|
||||
}
|
||||
|
||||
fun canOpen(type: FileType, platform: ReaderPlatform): Boolean {
|
||||
return surfaceFor(type, platform) != null
|
||||
}
|
||||
|
||||
fun readableTypesFor(platform: ReaderPlatform): Set<FileType> {
|
||||
return all.mapNotNullTo(mutableSetOf()) { capability ->
|
||||
capability.type.takeIf { capability.surfaceFor(platform) != null }
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
fun supportedFormatsLabel(platform: ReaderPlatform): String {
|
||||
return all
|
||||
.filter { it.surfaceFor(platform) != null }
|
||||
.joinToString(", ") { it.displayName }
|
||||
}
|
||||
|
||||
fun desktopParityGaps(): List<FileType> {
|
||||
return all
|
||||
.filter { it.isReadableOnAndroid && !it.isReadableOnDesktop }
|
||||
.map { it.type }
|
||||
}
|
||||
|
||||
private fun fileTypeForEffectiveName(fileName: String): FileType? {
|
||||
if (fileName.endsWith(".fb2.zip")) return FileType.FB2
|
||||
val extension = fileName.extensionAfterLastDot() ?: return null
|
||||
if (extension in codeOrDataExtensions) return FileType.HTML
|
||||
return typesByExtension[extension]
|
||||
}
|
||||
|
||||
private fun String.normalizedFileName(): String {
|
||||
return trim()
|
||||
.substringBefore('?')
|
||||
.substringBefore('#')
|
||||
.lowercase()
|
||||
}
|
||||
|
||||
private fun String.withTransparentTextSuffix(): String {
|
||||
if (!endsWith(".txt")) return this
|
||||
val innerName = removeSuffix(".txt")
|
||||
if (innerName.isBlank() || !innerName.contains('.')) return this
|
||||
return if (fileTypeForEffectiveName(innerName) != null) innerName else this
|
||||
}
|
||||
|
||||
private fun String.extensionAfterLastDot(): String? {
|
||||
val dotIndex = lastIndexOf('.')
|
||||
return if (dotIndex > 0 && dotIndex < lastIndex) substring(dotIndex + 1) else null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
package org.dueattendant149.bookreader.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
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
enum class SharedImportDecisionStatus {
|
||||
IMPORTABLE,
|
||||
DUPLICATE,
|
||||
UNSUPPORTED
|
||||
}
|
||||
|
||||
data class SharedImportDecision(
|
||||
val file: ImportedBookFile,
|
||||
val id: String,
|
||||
val type: FileType,
|
||||
val status: SharedImportDecisionStatus
|
||||
)
|
||||
|
||||
data class SharedImportPlan(
|
||||
val decisions: List<SharedImportDecision>,
|
||||
val importedBooks: List<BookItem>
|
||||
) {
|
||||
val supportedFiles: List<ImportedBookFile>
|
||||
get() = decisions
|
||||
.filterNot { it.status == SharedImportDecisionStatus.UNSUPPORTED }
|
||||
.map { it.file }
|
||||
|
||||
val importableFiles: List<ImportedBookFile>
|
||||
get() = supportedFiles
|
||||
|
||||
val importedFiles: List<ImportedBookFile>
|
||||
get() = decisions
|
||||
.filter { it.status == SharedImportDecisionStatus.IMPORTABLE }
|
||||
.map { it.file }
|
||||
|
||||
val duplicateFiles: List<ImportedBookFile>
|
||||
get() = decisions
|
||||
.filter { it.status == SharedImportDecisionStatus.DUPLICATE }
|
||||
.map { it.file }
|
||||
|
||||
val unsupportedFiles: List<ImportedBookFile>
|
||||
get() = decisions
|
||||
.filter { it.status == SharedImportDecisionStatus.UNSUPPORTED }
|
||||
.map { it.file }
|
||||
|
||||
val importedCount: Int get() = importedBooks.size
|
||||
val duplicateCount: Int get() = duplicateFiles.size
|
||||
val unsupportedCount: Int get() = unsupportedFiles.size
|
||||
}
|
||||
|
||||
data class SharedImportOutcomeCounts(
|
||||
val addedCount: Int = 0,
|
||||
val duplicateCount: Int = 0,
|
||||
val unsupportedCount: Int = 0,
|
||||
val failedCount: Int = 0
|
||||
)
|
||||
|
||||
data class SharedImportFeedback(
|
||||
val message: String,
|
||||
val isError: Boolean
|
||||
)
|
||||
|
||||
object SharedImportPlanner {
|
||||
fun plan(
|
||||
files: List<ImportedBookFile>,
|
||||
existingBookIds: Set<String>,
|
||||
platform: ReaderPlatform,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedImportPlan {
|
||||
val seenIds = existingBookIds.toMutableSet()
|
||||
val decisions = files.map { file ->
|
||||
val id = stableImportId(file)
|
||||
val type = SharedFileCapabilities.fileTypeForName(file.name)
|
||||
val status = when {
|
||||
!SharedFileCapabilities.canOpen(type, platform) -> SharedImportDecisionStatus.UNSUPPORTED
|
||||
!seenIds.add(id) -> SharedImportDecisionStatus.DUPLICATE
|
||||
else -> SharedImportDecisionStatus.IMPORTABLE
|
||||
}
|
||||
SharedImportDecision(
|
||||
file = file,
|
||||
id = id,
|
||||
type = type,
|
||||
status = status
|
||||
)
|
||||
}
|
||||
val importedBooks = decisions.mapIndexedNotNull { index, decision ->
|
||||
if (decision.status != SharedImportDecisionStatus.IMPORTABLE) return@mapIndexedNotNull null
|
||||
val file = decision.file
|
||||
BookItem(
|
||||
id = decision.id,
|
||||
path = file.localPath ?: file.uriString,
|
||||
type = decision.type,
|
||||
displayName = file.name,
|
||||
timestamp = nowMillis + index,
|
||||
title = file.name.substringBeforeLast('.'),
|
||||
fileSize = file.size,
|
||||
sourceFolder = file.sourceFolder,
|
||||
isRecent = false
|
||||
)
|
||||
}
|
||||
return SharedImportPlan(decisions, importedBooks)
|
||||
}
|
||||
|
||||
fun feedbackForCounts(
|
||||
counts: SharedImportOutcomeCounts,
|
||||
importedMessage: String,
|
||||
duplicateMessage: String,
|
||||
unsupportedMessage: String,
|
||||
failedMessage: String
|
||||
): SharedImportFeedback {
|
||||
val message = when {
|
||||
counts.addedCount > 0 -> importedMessage
|
||||
counts.duplicateCount > 0 -> duplicateMessage
|
||||
counts.unsupportedCount > 0 -> unsupportedMessage
|
||||
else -> failedMessage
|
||||
}
|
||||
return SharedImportFeedback(
|
||||
message = message,
|
||||
isError = counts.addedCount == 0 && counts.duplicateCount == 0
|
||||
)
|
||||
}
|
||||
|
||||
fun stableImportId(file: ImportedBookFile): String {
|
||||
return file.id ?: file.localPath ?: file.uriString ?: file.name
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderViewport
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderBookmark
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderSettings
|
||||
|
||||
enum class FileType {
|
||||
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, CBT, DOCX, ODT, FODT, PPTX, UNKNOWN
|
||||
}
|
||||
|
||||
val PDF_VIEWER_FILE_TYPES: Set<FileType>
|
||||
get() = SharedFileCapabilities.readableTypesFor(
|
||||
ReaderPlatform.ANDROID,
|
||||
ReaderFeatureSurface.PDF_VIEWER
|
||||
)
|
||||
|
||||
val EPUB_READER_FILE_TYPES: Set<FileType>
|
||||
get() = SharedFileCapabilities.readableTypesFor(
|
||||
ReaderPlatform.ANDROID,
|
||||
ReaderFeatureSurface.EPUB_READER
|
||||
)
|
||||
|
||||
enum class AddBooksSource {
|
||||
UNSHELVED,
|
||||
ALL_BOOKS
|
||||
}
|
||||
|
||||
enum class RenderMode {
|
||||
VERTICAL_SCROLL,
|
||||
PAGINATED
|
||||
}
|
||||
|
||||
enum class SortOrder {
|
||||
RECENT,
|
||||
TITLE_ASC,
|
||||
AUTHOR_ASC,
|
||||
PERCENT_ASC,
|
||||
PERCENT_DESC,
|
||||
SIZE_ASC,
|
||||
SIZE_DESC
|
||||
}
|
||||
|
||||
enum class ReadStatusFilter {
|
||||
ALL,
|
||||
UNREAD,
|
||||
IN_PROGRESS,
|
||||
COMPLETED
|
||||
}
|
||||
|
||||
const val IN_APP_STORAGE_SOURCE = "IN_APP_STORAGE"
|
||||
|
||||
enum class ShelfType {
|
||||
MANUAL,
|
||||
SMART,
|
||||
TAG,
|
||||
SERIES,
|
||||
FOLDER
|
||||
}
|
||||
|
||||
data class Tag(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val color: Int? = null
|
||||
)
|
||||
|
||||
data class SyncedFolder(
|
||||
val uriString: String,
|
||||
val name: String,
|
||||
val lastScanTime: Long,
|
||||
val allowedFileTypes: Set<FileType> = SharedFileCapabilities.knownFileTypes,
|
||||
val localSyncEnabled: Boolean = true
|
||||
)
|
||||
|
||||
data class BookItem(
|
||||
val id: String,
|
||||
val path: String?,
|
||||
val type: FileType,
|
||||
val displayName: String,
|
||||
val timestamp: Long,
|
||||
val coverImagePath: String? = null,
|
||||
val title: String? = null,
|
||||
val author: String? = null,
|
||||
val description: String? = null,
|
||||
val originalTitle: String? = null,
|
||||
val originalAuthor: String? = null,
|
||||
val originalSeriesName: String? = null,
|
||||
val originalSeriesIndex: Double? = null,
|
||||
val originalDescription: String? = null,
|
||||
val progressPercentage: Float? = null,
|
||||
val isRecent: Boolean = true,
|
||||
val fileSize: Long = 0L,
|
||||
val fileContentModifiedTimestamp: Long = 0L,
|
||||
val sourceFolder: String? = null,
|
||||
val folderTextMetadataParsed: Boolean = false,
|
||||
val seriesName: String? = null,
|
||||
val seriesIndex: Double? = null,
|
||||
val tags: List<Tag> = emptyList(),
|
||||
val lastPageIndex: Int? = null,
|
||||
val readerPosition: ReaderLocator? = null,
|
||||
val readerSettings: ReaderSettings? = null,
|
||||
val readerBookmarks: List<ReaderBookmark> = emptyList(),
|
||||
val readerHighlights: List<UserHighlight> = emptyList(),
|
||||
val pdfReaderViewport: SharedPdfReaderViewport? = null,
|
||||
val readingPositionModifiedTimestamp: Long = 0L
|
||||
)
|
||||
|
||||
data class Shelf(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val type: ShelfType,
|
||||
val books: List<BookItem>,
|
||||
val directBooks: List<BookItem> = books,
|
||||
val parentShelfId: String? = null,
|
||||
val childShelfIds: List<String> = emptyList(),
|
||||
val depth: Int = 0,
|
||||
val sortKey: String = name.lowercase()
|
||||
) {
|
||||
val bookCount: Int get() = books.size
|
||||
val topBook: BookItem? get() = books.maxByOrNull { it.timestamp }
|
||||
val directBookCount: Int get() = directBooks.size
|
||||
val childShelfCount: Int get() = childShelfIds.size
|
||||
}
|
||||
|
||||
data class LibraryFilters(
|
||||
val fileTypes: Set<FileType> = emptySet(),
|
||||
val sourceFolders: Set<String> = emptySet(),
|
||||
val readStatus: ReadStatusFilter = ReadStatusFilter.ALL,
|
||||
val tagIds: Set<String> = emptySet()
|
||||
) {
|
||||
val isActive: Boolean
|
||||
get() = fileTypes.isNotEmpty() ||
|
||||
sourceFolders.isNotEmpty() ||
|
||||
readStatus != ReadStatusFilter.ALL ||
|
||||
tagIds.isNotEmpty()
|
||||
}
|
||||
|
||||
data class LibraryState(
|
||||
val books: List<BookItem> = emptyList(),
|
||||
val searchQuery: String = "",
|
||||
val sortOrder: SortOrder = SortOrder.RECENT,
|
||||
val filters: LibraryFilters = LibraryFilters(),
|
||||
val selectedBookIds: Set<String> = emptySet(),
|
||||
val recentLimit: Int = 12,
|
||||
val message: String? = null,
|
||||
val messageText: SharedText? = null
|
||||
)
|
||||
|
||||
data class HomeScreenModel(
|
||||
val recentBooks: List<BookItem>,
|
||||
val selectedBooks: List<BookItem>,
|
||||
val isEmpty: Boolean
|
||||
)
|
||||
|
||||
data class LibraryScreenModel(
|
||||
val books: List<BookItem>,
|
||||
val shelves: List<Shelf>,
|
||||
val selectedBooks: List<BookItem>,
|
||||
val filters: LibraryFilters,
|
||||
val searchQuery: String,
|
||||
val sortOrder: SortOrder
|
||||
)
|
||||
|
|
@ -0,0 +1,480 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
data class SharedLibraryMutationResult(
|
||||
val state: SharedReaderScreenState,
|
||||
val shelfRecords: List<ShelfRecord>,
|
||||
val shelfRefs: List<BookShelfRef>
|
||||
)
|
||||
|
||||
object SharedLibraryEditor {
|
||||
fun cleanShelfName(name: String): String? {
|
||||
return name.trim().takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
fun canMutateShelf(shelfId: String?): Boolean {
|
||||
val trimmed = shelfId?.trim()
|
||||
return !trimmed.isNullOrBlank() && trimmed != "unshelved"
|
||||
}
|
||||
|
||||
fun createShelfRecord(
|
||||
name: String,
|
||||
id: String,
|
||||
isSmart: Boolean = false,
|
||||
smartRulesJson: String? = null
|
||||
): ShelfRecord? {
|
||||
val trimmed = cleanShelfName(name) ?: return null
|
||||
val trimmedId = id.trim().takeIf { it.isNotBlank() } ?: return null
|
||||
return ShelfRecord(
|
||||
id = trimmedId,
|
||||
name = trimmed,
|
||||
isSmart = isSmart,
|
||||
smartRulesJson = smartRulesJson
|
||||
)
|
||||
}
|
||||
|
||||
fun cleanTagName(name: String): String? {
|
||||
return name.trim().takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
fun createTag(
|
||||
name: String,
|
||||
id: String,
|
||||
color: Int? = 0xFF64B5F6.toInt()
|
||||
): Tag? {
|
||||
val trimmed = cleanTagName(name) ?: return null
|
||||
val trimmedId = id.trim().takeIf { it.isNotBlank() } ?: return null
|
||||
return Tag(
|
||||
id = trimmedId,
|
||||
name = trimmed,
|
||||
color = color
|
||||
)
|
||||
}
|
||||
|
||||
fun cleanBookIds(bookIds: Iterable<String>): Set<String> {
|
||||
return bookIds.mapTo(mutableSetOf()) { it.trim() }.filterTo(mutableSetOf()) { it.isNotBlank() }
|
||||
}
|
||||
|
||||
fun removeSelectedBooks(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>
|
||||
): SharedLibraryMutationResult? {
|
||||
val selected = state.selectedBookIds
|
||||
if (selected.isEmpty()) return null
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(
|
||||
rawLibraryBooks = state.rawLibraryBooks.filterNot { it.id in selected },
|
||||
selectedBookIds = emptySet(),
|
||||
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 }
|
||||
)
|
||||
}
|
||||
|
||||
fun createShelf(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
name: String,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedLibraryMutationResult? {
|
||||
val trimmed = cleanShelfName(name) ?: return null
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(
|
||||
bannerMessage = BannerMessage.string(
|
||||
"banner_shelf_created",
|
||||
"Created shelf \"%1\$s\".",
|
||||
trimmed
|
||||
)
|
||||
),
|
||||
shelfRecords = shelfRecords + ShelfRecord(id = "shelf_$nowMillis", name = trimmed),
|
||||
shelfRefs = shelfRefs
|
||||
)
|
||||
}
|
||||
|
||||
fun createShelfWithBooks(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
name: String,
|
||||
bookIds: Iterable<String>,
|
||||
clearSelection: Boolean = true,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedLibraryMutationResult? {
|
||||
val trimmed = cleanShelfName(name) ?: return null
|
||||
val selectedBooks = cleanBookIds(bookIds)
|
||||
val shelfId = "shelf_$nowMillis"
|
||||
val newRefs = selectedBooks.map { bookId ->
|
||||
BookShelfRef(bookId = bookId, shelfId = shelfId, addedAt = nowMillis)
|
||||
}
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(
|
||||
selectedBookIds = if (clearSelection && selectedBooks.isNotEmpty()) emptySet() else state.selectedBookIds,
|
||||
bannerMessage = if (selectedBooks.isEmpty()) {
|
||||
BannerMessage.string(
|
||||
"banner_shelf_created",
|
||||
"Created shelf \"%1\$s\".",
|
||||
trimmed
|
||||
)
|
||||
} else {
|
||||
BannerMessage.quantity(
|
||||
"banner_shelf_created_with_books",
|
||||
selectedBooks.size,
|
||||
"Created shelf \"%1\$s\" with %2\$d book.",
|
||||
"Created shelf \"%1\$s\" with %2\$d books.",
|
||||
trimmed,
|
||||
selectedBooks.size
|
||||
)
|
||||
}
|
||||
),
|
||||
shelfRecords = shelfRecords + ShelfRecord(id = shelfId, name = trimmed),
|
||||
shelfRefs = shelfRefs + newRefs
|
||||
)
|
||||
}
|
||||
|
||||
fun createSmartShelf(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
name: String,
|
||||
definition: SmartCollectionDefinition,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedLibraryMutationResult? {
|
||||
val trimmed = cleanShelfName(name) ?: return null
|
||||
val cleanedRules = definition.rules.mapNotNull { rule ->
|
||||
rule.value.trim().takeIf { it.isNotBlank() }?.let { value -> rule.copy(value = value) }
|
||||
}
|
||||
if (cleanedRules.isEmpty()) return null
|
||||
val cleanedDefinition = definition.copy(rules = cleanedRules)
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(
|
||||
bannerMessage = BannerMessage.string(
|
||||
"banner_smart_shelf_created",
|
||||
"Created smart shelf \"%1\$s\".",
|
||||
trimmed
|
||||
)
|
||||
),
|
||||
shelfRecords = shelfRecords + ShelfRecord(
|
||||
id = "smart_$nowMillis",
|
||||
name = trimmed,
|
||||
isSmart = true,
|
||||
smartRulesJson = SmartCollectionEngine.toJson(cleanedDefinition)
|
||||
),
|
||||
shelfRefs = shelfRefs
|
||||
)
|
||||
}
|
||||
|
||||
fun renameShelf(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
shelf: Shelf,
|
||||
name: String
|
||||
): SharedLibraryMutationResult? {
|
||||
val trimmed = cleanShelfName(name) ?: return null
|
||||
return SharedLibraryMutationResult(
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
fun deleteShelf(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
shelf: Shelf
|
||||
): SharedLibraryMutationResult {
|
||||
return SharedLibraryMutationResult(
|
||||
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 }
|
||||
)
|
||||
}
|
||||
|
||||
fun removeFolder(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
folder: Shelf
|
||||
): SharedLibraryMutationResult? {
|
||||
if (folder.type != ShelfType.FOLDER) return null
|
||||
val folderBookIds = cleanBookIds(folder.books.map { it.id })
|
||||
if (folderBookIds.isEmpty()) return null
|
||||
val rootSourceFolder = folder.books.firstNotNullOfOrNull { it.sourceFolder }
|
||||
val remainingTabs = state.openTabIds.filterNot { it in folderBookIds }
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(
|
||||
rawLibraryBooks = state.rawLibraryBooks.filterNot { it.id in folderBookIds },
|
||||
selectedBookIds = state.selectedBookIds - folderBookIds,
|
||||
pinnedHomeBookIds = state.pinnedHomeBookIds - folderBookIds,
|
||||
pinnedLibraryBookIds = state.pinnedLibraryBookIds - folderBookIds,
|
||||
openTabIds = remainingTabs,
|
||||
activeTabBookId = state.activeTabBookId?.takeUnless { it in folderBookIds },
|
||||
syncedFolders = if (folder.parentShelfId == null && rootSourceFolder != null) {
|
||||
state.syncedFolders.filterNot { it.uriString == rootSourceFolder }
|
||||
} else {
|
||||
state.syncedFolders
|
||||
},
|
||||
libraryFilters = if (rootSourceFolder != null) {
|
||||
state.libraryFilters.copy(sourceFolders = state.libraryFilters.sourceFolders - rootSourceFolder)
|
||||
} else {
|
||||
state.libraryFilters
|
||||
},
|
||||
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 }
|
||||
)
|
||||
}
|
||||
|
||||
fun markBookOpened(
|
||||
state: SharedReaderScreenState,
|
||||
bookId: String,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedReaderScreenState {
|
||||
val cleanedBookId = bookId.trim()
|
||||
if (cleanedBookId.isBlank()) return state
|
||||
return state.copy(
|
||||
rawLibraryBooks = state.rawLibraryBooks.map { book ->
|
||||
if (book.id == cleanedBookId) {
|
||||
book.copy(isRecent = true, timestamp = nowMillis)
|
||||
} else {
|
||||
book
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun addSelectedBooksToShelf(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
shelfId: String,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedLibraryMutationResult? {
|
||||
return addBooksToShelves(
|
||||
state = state,
|
||||
shelfRecords = shelfRecords,
|
||||
shelfRefs = shelfRefs,
|
||||
bookIds = state.selectedBookIds,
|
||||
shelfIds = listOf(shelfId),
|
||||
clearSelection = true,
|
||||
nowMillis = nowMillis,
|
||||
bannerName = "banner_books_added_to_shelf",
|
||||
singularMessage = "%1\$d book added to shelf.",
|
||||
pluralMessage = "%1\$d books added to shelf."
|
||||
)
|
||||
}
|
||||
|
||||
fun addBooksToShelves(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
bookIds: Iterable<String>,
|
||||
shelfIds: Iterable<String>,
|
||||
clearSelection: Boolean = true,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedLibraryMutationResult? {
|
||||
return addBooksToShelves(
|
||||
state = state,
|
||||
shelfRecords = shelfRecords,
|
||||
shelfRefs = shelfRefs,
|
||||
bookIds = bookIds,
|
||||
shelfIds = shelfIds,
|
||||
clearSelection = clearSelection,
|
||||
nowMillis = nowMillis,
|
||||
bannerName = "banner_books_added_to_shelves",
|
||||
singularMessage = "%1\$d shelf entry added.",
|
||||
pluralMessage = "%1\$d shelf entries added."
|
||||
)
|
||||
}
|
||||
|
||||
fun replaceShelfBooks(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
shelfId: String,
|
||||
bookIds: Iterable<String>,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedLibraryMutationResult? {
|
||||
val cleanShelfId = shelfId.trim()
|
||||
if (!canMutateShelf(cleanShelfId)) return null
|
||||
val selectedBooks = cleanBookIds(bookIds)
|
||||
val shelfName = state.shelves.firstOrNull { it.id == cleanShelfId }?.name
|
||||
?: shelfRecords.firstOrNull { it.id == cleanShelfId }?.name
|
||||
?: cleanShelfId
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(
|
||||
bannerMessage = BannerMessage.quantity(
|
||||
"banner_shelf_books_updated",
|
||||
selectedBooks.size,
|
||||
"Updated \"%1\$s\" with %2\$d book.",
|
||||
"Updated \"%1\$s\" with %2\$d books.",
|
||||
shelfName,
|
||||
selectedBooks.size
|
||||
)
|
||||
),
|
||||
shelfRecords = shelfRecords,
|
||||
shelfRefs = shelfRefs.filterNot { it.shelfId == cleanShelfId } +
|
||||
selectedBooks.map { bookId ->
|
||||
BookShelfRef(bookId = bookId, shelfId = cleanShelfId, addedAt = nowMillis)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun tagSelectedBooks(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
tagName: String,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedLibraryMutationResult? {
|
||||
val selected = cleanBookIds(state.selectedBookIds)
|
||||
val trimmed = cleanTagName(tagName) ?: return null
|
||||
if (selected.isEmpty()) return null
|
||||
val existingTag = state.allTags.firstOrNull { it.name.equals(trimmed, ignoreCase = true) }
|
||||
val tag = existingTag ?: Tag(
|
||||
id = trimmed.toStableTagId("tag_$nowMillis"),
|
||||
name = trimmed,
|
||||
color = 0xFF64B5F6.toInt()
|
||||
)
|
||||
val allTags = (state.allTags + tag).distinctBy { it.id }.sortedBy { it.name.lowercase() }
|
||||
val books = state.rawLibraryBooks.map { book ->
|
||||
if (book.id in selected && book.tags.none { it.id == tag.id }) {
|
||||
book.copy(tags = (book.tags + tag).sortedBy { it.name.lowercase() })
|
||||
} else {
|
||||
book
|
||||
}
|
||||
}
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(
|
||||
rawLibraryBooks = books,
|
||||
allTags = allTags,
|
||||
selectedBookIds = emptySet(),
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
fun updateBookMetadata(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
updated: BookItem,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedLibraryMutationResult {
|
||||
return SharedLibraryMutationResult(
|
||||
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.string(
|
||||
"banner_book_updated",
|
||||
"Updated \"%1\$s\".",
|
||||
updated.cardTitle()
|
||||
)
|
||||
),
|
||||
shelfRecords = shelfRecords,
|
||||
shelfRefs = shelfRefs
|
||||
)
|
||||
}
|
||||
|
||||
private fun addBooksToShelves(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
bookIds: Iterable<String>,
|
||||
shelfIds: Iterable<String>,
|
||||
clearSelection: Boolean,
|
||||
nowMillis: Long,
|
||||
bannerName: String,
|
||||
singularMessage: String,
|
||||
pluralMessage: String
|
||||
): SharedLibraryMutationResult? {
|
||||
val selectedBooks = cleanBookIds(bookIds)
|
||||
val targetShelfIds = shelfIds
|
||||
.map { it.trim() }
|
||||
.filter { canMutateShelf(it) }
|
||||
.distinct()
|
||||
if (selectedBooks.isEmpty() || targetShelfIds.isEmpty()) return null
|
||||
|
||||
val existing = shelfRefs.mapTo(mutableSetOf()) { it.bookId to it.shelfId }
|
||||
val additions = targetShelfIds.flatMap { shelfId ->
|
||||
selectedBooks.mapNotNull { bookId ->
|
||||
if (!existing.add(bookId to shelfId)) {
|
||||
null
|
||||
} else {
|
||||
BookShelfRef(bookId = bookId, shelfId = shelfId, addedAt = nowMillis)
|
||||
}
|
||||
}
|
||||
}
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(
|
||||
selectedBookIds = if (clearSelection) emptySet() else state.selectedBookIds,
|
||||
bannerMessage = BannerMessage.quantity(
|
||||
bannerName,
|
||||
additions.size,
|
||||
singularMessage,
|
||||
pluralMessage,
|
||||
additions.size
|
||||
)
|
||||
),
|
||||
shelfRecords = shelfRecords,
|
||||
shelfRefs = shelfRefs + additions
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun parseTagList(input: String, knownTags: List<Tag>, nowMillis: Long = currentTimestamp()): List<Tag> {
|
||||
return input.split(',')
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinctBy { it.lowercase() }
|
||||
.mapIndexed { index, name ->
|
||||
knownTags.firstOrNull { it.name.equals(name, ignoreCase = true) }
|
||||
?: Tag(
|
||||
id = name.toStableTagId("tag_${nowMillis + index}"),
|
||||
name = name,
|
||||
color = 0xFF64B5F6.toInt()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toStableTagId(fallback: String): String {
|
||||
return lowercase().replace(Regex("[^a-z0-9]+"), "_").trim('_').ifBlank { fallback }
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
class LibraryProjector {
|
||||
fun home(state: LibraryState): HomeScreenModel {
|
||||
val recentBooks = sortBooks(state.books.filter { it.isRecent }, state.sortOrder)
|
||||
.take(state.recentLimit)
|
||||
return HomeScreenModel(
|
||||
recentBooks = recentBooks,
|
||||
selectedBooks = state.books.filter { it.id in state.selectedBookIds },
|
||||
isEmpty = recentBooks.isEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
fun library(state: LibraryState): LibraryScreenModel {
|
||||
val searched = filterBySearch(state.books, state.searchQuery)
|
||||
val filtered = applyFilters(searched, state.filters)
|
||||
val sorted = sortBooks(filtered, state.sortOrder)
|
||||
|
||||
return LibraryScreenModel(
|
||||
books = sorted,
|
||||
shelves = buildShelves(state.books),
|
||||
selectedBooks = state.books.filter { it.id in state.selectedBookIds },
|
||||
filters = state.filters,
|
||||
searchQuery = state.searchQuery,
|
||||
sortOrder = state.sortOrder
|
||||
)
|
||||
}
|
||||
|
||||
fun withImportedFiles(state: LibraryState, files: List<ImportedFile>): LibraryState {
|
||||
if (files.isEmpty()) return state
|
||||
val plan = SharedImportPlanner.plan(
|
||||
files = files.map { it.toImportedBookFile() },
|
||||
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 = messageText.fallbackMessage(),
|
||||
messageText = messageText
|
||||
)
|
||||
}
|
||||
|
||||
fun sortBooks(books: List<BookItem>, sortOrder: SortOrder): List<BookItem> {
|
||||
return when (sortOrder) {
|
||||
SortOrder.RECENT -> books.sortedByDescending { it.timestamp }
|
||||
SortOrder.TITLE_ASC -> books.sortedBy { it.title?.lowercase() ?: it.displayName.lowercase() }
|
||||
SortOrder.AUTHOR_ASC -> books.sortedWith(compareBy(nullsLast()) { it.author?.lowercase() })
|
||||
SortOrder.PERCENT_ASC -> books.sortedBy { it.progressPercentage ?: 0f }
|
||||
SortOrder.PERCENT_DESC -> books.sortedByDescending { it.progressPercentage ?: 0f }
|
||||
SortOrder.SIZE_ASC -> books.sortedBy { it.fileSize }
|
||||
SortOrder.SIZE_DESC -> books.sortedByDescending { it.fileSize }
|
||||
}
|
||||
}
|
||||
|
||||
fun filterBySearch(books: List<BookItem>, query: String): List<BookItem> {
|
||||
val normalized = query.trim()
|
||||
if (normalized.isBlank()) return books
|
||||
return books.filter { book ->
|
||||
book.displayName.contains(normalized, ignoreCase = true) ||
|
||||
book.title?.contains(normalized, ignoreCase = true) == true ||
|
||||
book.author?.contains(normalized, ignoreCase = true) == true ||
|
||||
book.tags.any { it.name.contains(normalized, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
|
||||
fun applyFilters(books: List<BookItem>, filters: LibraryFilters): List<BookItem> {
|
||||
return books.filter { book ->
|
||||
val matchesType = filters.fileTypes.isEmpty() || book.type in filters.fileTypes
|
||||
val matchesFolder = book.matchesSourceFolders(filters.sourceFolders)
|
||||
val progress = book.progressPercentage ?: 0f
|
||||
val matchesStatus = when (filters.readStatus) {
|
||||
ReadStatusFilter.ALL -> true
|
||||
ReadStatusFilter.UNREAD -> progress == 0f
|
||||
ReadStatusFilter.IN_PROGRESS -> progress > 0f && progress < 100f
|
||||
ReadStatusFilter.COMPLETED -> progress >= 100f
|
||||
}
|
||||
val matchesTags = filters.tagIds.isEmpty() || book.tags.any { it.id in filters.tagIds }
|
||||
matchesType && matchesFolder && matchesStatus && matchesTags
|
||||
}
|
||||
}
|
||||
|
||||
fun buildShelves(books: List<BookItem>): List<Shelf> {
|
||||
val seriesShelves = books
|
||||
.filter { !it.seriesName.isNullOrBlank() }
|
||||
.groupBy { it.seriesName.orEmpty() }
|
||||
.filter { it.value.size > 1 }
|
||||
.map { (series, seriesBooks) ->
|
||||
Shelf(
|
||||
id = "series_$series",
|
||||
name = series,
|
||||
type = ShelfType.SERIES,
|
||||
books = seriesBooks.sortedBy { it.seriesIndex ?: 999.0 }
|
||||
)
|
||||
}
|
||||
|
||||
val folderShelves = books
|
||||
.filter { it.sourceFolder != null }
|
||||
.groupBy { it.sourceFolder.orEmpty() }
|
||||
.map { (folder, folderBooks) ->
|
||||
Shelf(
|
||||
id = "folder_$folder",
|
||||
name = folder.folderDisplayName(),
|
||||
type = ShelfType.FOLDER,
|
||||
books = sortBooks(folderBooks, SortOrder.TITLE_ASC)
|
||||
)
|
||||
}
|
||||
|
||||
val tagShelves = books
|
||||
.flatMap { book -> book.tags.map { tag -> tag to book } }
|
||||
.groupBy({ it.first }, { it.second })
|
||||
.map { (tag, taggedBooks) ->
|
||||
Shelf(
|
||||
id = "tag_${tag.id}",
|
||||
name = tag.name,
|
||||
type = ShelfType.TAG,
|
||||
books = sortBooks(taggedBooks, SortOrder.TITLE_ASC)
|
||||
)
|
||||
}
|
||||
|
||||
return (seriesShelves + folderShelves + tagShelves)
|
||||
.sortedWith(compareBy({ it.type.ordinal }, { it.name.lowercase() }))
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.folderDisplayName(): String {
|
||||
return replace('\\', '/').trimEnd('/').substringAfterLast('/').ifBlank { "Local Folder" }
|
||||
}
|
||||
|
||||
data class ImportedFile(
|
||||
val name: String,
|
||||
val path: String?,
|
||||
val size: Long,
|
||||
val sourceFolder: String? = null
|
||||
)
|
||||
|
||||
private fun ImportedFile.toImportedBookFile(): ImportedBookFile {
|
||||
return ImportedBookFile(
|
||||
name = name,
|
||||
uriString = null,
|
||||
localPath = path,
|
||||
size = size,
|
||||
sourceFolder = sourceFolder
|
||||
)
|
||||
}
|
||||
|
||||
fun String.toFileType(): FileType {
|
||||
return SharedFileCapabilities.fileTypeForName(this)
|
||||
}
|
||||
|
|
@ -0,0 +1,360 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
data class ShelfRecord(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val isSmart: Boolean = false,
|
||||
val smartRulesJson: String? = null
|
||||
)
|
||||
|
||||
data class BookShelfRef(
|
||||
val bookId: String,
|
||||
val shelfId: String,
|
||||
val addedAt: Long
|
||||
)
|
||||
|
||||
fun interface SharedFolderPathResolver {
|
||||
fun relativeFolderSegments(item: BookItem): List<String>
|
||||
}
|
||||
|
||||
object EmptySharedFolderPathResolver : SharedFolderPathResolver {
|
||||
override fun relativeFolderSegments(item: BookItem): List<String> = emptyList()
|
||||
}
|
||||
|
||||
data class SharedLibraryProjectionInput(
|
||||
val state: SharedReaderScreenState,
|
||||
val booksFromStore: List<BookItem>,
|
||||
val shelfRecords: List<ShelfRecord>,
|
||||
val shelfRefs: List<BookShelfRef>,
|
||||
val tags: List<Tag>
|
||||
)
|
||||
|
||||
class SharedLibraryStateProjector(
|
||||
private val folderPathResolver: SharedFolderPathResolver = EmptySharedFolderPathResolver
|
||||
) {
|
||||
fun project(input: SharedLibraryProjectionInput): SharedReaderScreenState {
|
||||
val current = input.state
|
||||
val allLibraryBooks = input.booksFromStore
|
||||
val syncedFolders = current.syncedFolders.withSourceFolderFallbacks(allLibraryBooks)
|
||||
val queried = filterBySearch(allLibraryBooks, current.searchQuery)
|
||||
val filtered = applyLibraryFilters(queried, current.libraryFilters)
|
||||
val sortedLibraryBooks = sortBooks(filtered, current.sortOrder)
|
||||
.withPinnedFirst(current.pinnedLibraryBookIds)
|
||||
val visibleRecentBooks = sortBooks(
|
||||
allLibraryBooks.filter { it.isRecent },
|
||||
current.sortOrder
|
||||
)
|
||||
.withPinnedFirst(current.pinnedHomeBookIds)
|
||||
.take(if (current.recentFilesLimit > 0) current.recentFilesLimit else Int.MAX_VALUE)
|
||||
val openTabs = current.openTabIds.mapNotNull { tabId -> allLibraryBooks.find { it.id == tabId } }
|
||||
val openTabIds = openTabs.map { it.id }
|
||||
val activeTabBookId = current.activeTabBookId?.takeIf { it in openTabIds }
|
||||
val shelfProjection = buildShelves(
|
||||
allLibraryBooks = allLibraryBooks,
|
||||
shelfRecords = input.shelfRecords,
|
||||
shelfRefs = input.shelfRefs,
|
||||
tags = input.tags,
|
||||
sortOrder = current.sortOrder,
|
||||
syncedFolders = syncedFolders
|
||||
)
|
||||
val validShelfIds = shelfProjection.shelves.mapTo(mutableSetOf()) { it.id }
|
||||
val viewingShelfId = current.viewingShelfId?.takeIf { it in validShelfIds }
|
||||
val selectedShelfIds = current.selectedShelfIds.filterTo(mutableSetOf()) { it in validShelfIds }
|
||||
val booksAvailableForAdding = if (current.isAddingBooksToShelf && viewingShelfId != null) {
|
||||
val currentShelfBookIds = shelfProjection.shelves
|
||||
.find { it.id == viewingShelfId }
|
||||
?.books
|
||||
?.map { it.id }
|
||||
?.toSet()
|
||||
?: emptySet()
|
||||
when (current.addBooksSource) {
|
||||
AddBooksSource.UNSHELVED -> shelfProjection.unshelvedBooks
|
||||
AddBooksSource.ALL_BOOKS -> allLibraryBooks.filter { it.id !in currentShelfBookIds }
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
return current.copy(
|
||||
recentBooks = visibleRecentBooks,
|
||||
libraryBooks = sortedLibraryBooks,
|
||||
rawLibraryBooks = allLibraryBooks,
|
||||
viewingShelfId = viewingShelfId,
|
||||
isAddingBooksToShelf = current.isAddingBooksToShelf && viewingShelfId != null,
|
||||
selectedShelfIds = selectedShelfIds,
|
||||
selectedBookIds = current.selectedBookIds.filterTo(mutableSetOf()) { selectedId ->
|
||||
allLibraryBooks.any { it.id == selectedId }
|
||||
},
|
||||
shelves = shelfProjection.shelves,
|
||||
openTabs = openTabs,
|
||||
openTabIds = openTabIds,
|
||||
activeTabBookId = activeTabBookId,
|
||||
booksAvailableForAdding = booksAvailableForAdding,
|
||||
allTags = input.tags,
|
||||
syncedFolders = syncedFolders
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildShelves(
|
||||
allLibraryBooks: List<BookItem>,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
tags: List<Tag>,
|
||||
sortOrder: SortOrder,
|
||||
syncedFolders: List<SyncedFolder>
|
||||
): ShelfProjection {
|
||||
val shelves = mutableListOf<Shelf>()
|
||||
val shelvedBookIds = mutableSetOf<String>()
|
||||
val booksById = allLibraryBooks.associateBy { it.id }
|
||||
|
||||
shelfRecords.forEach { shelf ->
|
||||
if (shelf.isSmart && shelf.smartRulesJson != null) {
|
||||
val definition = SmartCollectionEngine.fromJson(shelf.smartRulesJson)
|
||||
if (definition != null) {
|
||||
val matchingBooks = allLibraryBooks.filter { SmartCollectionEngine.evaluate(it, definition) }
|
||||
shelves.add(Shelf(shelf.id, shelf.name, ShelfType.SMART, sortBooks(matchingBooks, sortOrder)))
|
||||
shelvedBookIds.addAll(matchingBooks.map { it.id })
|
||||
}
|
||||
} else {
|
||||
val bookIds = shelfRefs
|
||||
.filter { it.shelfId == shelf.id }
|
||||
.sortedBy { it.addedAt }
|
||||
.map { it.bookId }
|
||||
val books = bookIds.mapNotNull { booksById[it] }
|
||||
shelves.add(Shelf(shelf.id, shelf.name, ShelfType.MANUAL, sortBooks(books, sortOrder)))
|
||||
shelvedBookIds.addAll(bookIds)
|
||||
}
|
||||
}
|
||||
|
||||
val tagShelves = tags.mapNotNull { tag ->
|
||||
val taggedBooks = allLibraryBooks.filter { book -> book.tags.any { it.id == tag.id } }
|
||||
if (taggedBooks.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
Shelf("tag_${tag.id}", tag.name, ShelfType.TAG, sortBooks(taggedBooks, sortOrder))
|
||||
}
|
||||
}
|
||||
shelves.addAll(tagShelves)
|
||||
|
||||
val seriesShelves = allLibraryBooks
|
||||
.filter { !it.seriesName.isNullOrBlank() }
|
||||
.groupBy { it.seriesName.orEmpty() }
|
||||
.filter { it.value.size >= 2 }
|
||||
.map { (series, books) ->
|
||||
val sortedSeries = books.sortedBy { it.seriesIndex ?: 999.0 }
|
||||
shelvedBookIds.addAll(books.map { it.id })
|
||||
Shelf("series_$series", series, ShelfType.SERIES, sortedSeries)
|
||||
}
|
||||
shelves.addAll(seriesShelves)
|
||||
|
||||
val folderShelves = buildFolderShelves(allLibraryBooks, syncedFolders, sortOrder)
|
||||
folderShelves.forEach { shelf -> shelvedBookIds.addAll(shelf.books.map { it.id }) }
|
||||
shelves.addAll(folderShelves)
|
||||
|
||||
val unshelvedBooks = allLibraryBooks.filter { it.id !in shelvedBookIds }
|
||||
shelves.add(Shelf("unshelved", "Unshelved", ShelfType.MANUAL, sortBooks(unshelvedBooks, sortOrder)))
|
||||
|
||||
shelves.sortWith(compareBy({ it.type.ordinal }, { it.sortKey }))
|
||||
return ShelfProjection(shelves = shelves, unshelvedBooks = unshelvedBooks)
|
||||
}
|
||||
|
||||
private fun buildFolderShelves(
|
||||
allLibraryBooks: List<BookItem>,
|
||||
syncedFolders: List<SyncedFolder>,
|
||||
sortOrder: SortOrder
|
||||
): List<Shelf> {
|
||||
val folderNamesByUri = syncedFolders.associate { it.uriString to it.name }
|
||||
return allLibraryBooks
|
||||
.filter { it.sourceFolder != null }
|
||||
.groupBy { it.sourceFolder.orEmpty() }
|
||||
.flatMap { (folderUri, books) ->
|
||||
val rootName = folderNamesByUri[folderUri] ?: folderUri.folderDisplayName()
|
||||
val rootShelfId = "folder_$folderUri"
|
||||
val rootAccumulator = FolderShelfAccumulator(
|
||||
id = rootShelfId,
|
||||
name = rootName,
|
||||
depth = 0,
|
||||
parentShelfId = null,
|
||||
sortPath = ""
|
||||
)
|
||||
val nestedShelves = linkedMapOf<String, FolderShelfAccumulator>()
|
||||
books.forEach { book ->
|
||||
rootAccumulator.books.add(book)
|
||||
val segments = folderPathResolver.relativeFolderSegments(book)
|
||||
if (segments.isEmpty()) rootAccumulator.directBooks.add(book)
|
||||
var currentPath = ""
|
||||
var parentShelfId = rootShelfId
|
||||
segments.forEachIndexed { index, segment ->
|
||||
currentPath = if (currentPath.isEmpty()) segment else "$currentPath/$segment"
|
||||
val shelfId = "folder_$folderUri::$currentPath"
|
||||
val accumulator = nestedShelves.getOrPut(currentPath) {
|
||||
val newShelf = FolderShelfAccumulator(
|
||||
id = shelfId,
|
||||
name = segment,
|
||||
depth = index + 1,
|
||||
parentShelfId = parentShelfId,
|
||||
sortPath = currentPath.lowercase()
|
||||
)
|
||||
if (parentShelfId == rootShelfId) {
|
||||
rootAccumulator.childShelfIds.add(shelfId)
|
||||
} else {
|
||||
nestedShelves.values.find { it.id == parentShelfId }?.childShelfIds?.add(shelfId)
|
||||
}
|
||||
newShelf
|
||||
}
|
||||
accumulator.books.add(book)
|
||||
if (index == segments.lastIndex) accumulator.directBooks.add(book)
|
||||
parentShelfId = shelfId
|
||||
}
|
||||
}
|
||||
|
||||
val rootShelf = Shelf(
|
||||
id = rootShelfId,
|
||||
name = rootName,
|
||||
type = ShelfType.FOLDER,
|
||||
books = sortBooks(books, sortOrder),
|
||||
directBooks = sortBooks(rootAccumulator.directBooks, sortOrder),
|
||||
childShelfIds = rootAccumulator.childShelfIds.sortedBy { it.substringAfterLast("::").lowercase() },
|
||||
depth = 0,
|
||||
sortKey = "folder:${rootName.lowercase()}:"
|
||||
)
|
||||
|
||||
val childShelves = nestedShelves.values.sortedBy { it.sortPath }.map { shelf ->
|
||||
Shelf(
|
||||
id = shelf.id,
|
||||
name = shelf.name,
|
||||
type = ShelfType.FOLDER,
|
||||
books = sortBooks(shelf.books, sortOrder),
|
||||
directBooks = sortBooks(shelf.directBooks, sortOrder),
|
||||
parentShelfId = shelf.parentShelfId,
|
||||
childShelfIds = shelf.childShelfIds.sortedBy { it.substringAfterLast("::").lowercase() },
|
||||
depth = shelf.depth,
|
||||
sortKey = "folder:${rootName.lowercase()}:${shelf.sortPath}"
|
||||
)
|
||||
}
|
||||
|
||||
listOf(rootShelf) + childShelves
|
||||
}
|
||||
}
|
||||
|
||||
private data class ShelfProjection(
|
||||
val shelves: List<Shelf>,
|
||||
val unshelvedBooks: List<BookItem>
|
||||
)
|
||||
|
||||
private data class FolderShelfAccumulator(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val depth: Int,
|
||||
val parentShelfId: String?,
|
||||
val sortPath: String,
|
||||
val books: MutableList<BookItem> = mutableListOf(),
|
||||
val directBooks: MutableList<BookItem> = mutableListOf(),
|
||||
val childShelfIds: MutableList<String> = mutableListOf()
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<SyncedFolder>.withSourceFolderFallbacks(books: List<BookItem>): List<SyncedFolder> {
|
||||
val knownFolders = mapTo(linkedSetOf()) { it.uriString }
|
||||
val missingFolders = books
|
||||
.mapNotNull { it.sourceFolder?.takeIf(String::isNotBlank) }
|
||||
.filterTo(linkedSetOf()) { knownFolders.add(it) }
|
||||
.map { sourceFolder ->
|
||||
SyncedFolder(
|
||||
uriString = sourceFolder,
|
||||
name = sourceFolder.folderDisplayName(),
|
||||
lastScanTime = 0L
|
||||
)
|
||||
}
|
||||
return if (missingFolders.isEmpty()) this else this + missingFolders
|
||||
}
|
||||
|
||||
private fun String.folderDisplayName(): String {
|
||||
return replace('\\', '/').trimEnd('/').substringAfterLast('/').ifBlank { "Local Folder" }
|
||||
}
|
||||
|
||||
fun filterBySearch(books: List<BookItem>, searchQuery: String): List<BookItem> {
|
||||
val query = searchQuery.trim()
|
||||
return if (query.isBlank()) {
|
||||
books
|
||||
} else {
|
||||
books.filter { book ->
|
||||
book.displayName.contains(query, ignoreCase = true) ||
|
||||
book.title?.contains(query, ignoreCase = true) == true ||
|
||||
book.author?.contains(query, ignoreCase = true) == true ||
|
||||
book.tags.any { tag -> tag.name.contains(query, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun applyLibraryFilters(books: List<BookItem>, filters: LibraryFilters): List<BookItem> {
|
||||
return books.filter { book ->
|
||||
val matchType = filters.fileTypes.isEmpty() || book.type in filters.fileTypes
|
||||
val matchFolder = book.matchesSourceFolders(filters.sourceFolders)
|
||||
val progress = book.progressPercentage ?: 0f
|
||||
val matchStatus = when (filters.readStatus) {
|
||||
ReadStatusFilter.ALL -> true
|
||||
ReadStatusFilter.UNREAD -> progress == 0f
|
||||
ReadStatusFilter.IN_PROGRESS -> progress > 0f && progress < 100f
|
||||
ReadStatusFilter.COMPLETED -> progress >= 100f
|
||||
}
|
||||
val matchTags = filters.tagIds.isEmpty() || book.tags.any { it.id in filters.tagIds }
|
||||
matchType && matchFolder && matchStatus && matchTags
|
||||
}
|
||||
}
|
||||
|
||||
fun sortBooks(books: List<BookItem>, sortOrder: SortOrder): List<BookItem> {
|
||||
return when (sortOrder) {
|
||||
SortOrder.RECENT -> books.sortedByDescending { it.timestamp }
|
||||
SortOrder.TITLE_ASC -> books.sortedBy { it.title?.lowercase() ?: it.displayName.lowercase() }
|
||||
SortOrder.AUTHOR_ASC -> books.sortedWith(compareBy(nullsLast()) { it.author?.lowercase() })
|
||||
SortOrder.PERCENT_ASC -> books.sortedBy { it.progressPercentage ?: 0f }
|
||||
SortOrder.PERCENT_DESC -> books.sortedByDescending { it.progressPercentage ?: 0f }
|
||||
SortOrder.SIZE_ASC -> books.sortedBy { it.fileSize }
|
||||
SortOrder.SIZE_DESC -> books.sortedByDescending { it.fileSize }
|
||||
}
|
||||
}
|
||||
|
||||
fun SharedReaderScreenState.withImportedFiles(
|
||||
files: List<ImportedBookFile>,
|
||||
now: Long = currentTimestamp()
|
||||
): SharedReaderScreenState {
|
||||
if (files.isEmpty()) return this
|
||||
val plan = SharedImportPlanner.plan(
|
||||
files = files,
|
||||
existingBookIds = rawLibraryBooks.mapTo(mutableSetOf()) { it.id },
|
||||
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 = banner
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<BookItem>.withPinnedFirst(pinnedBookIds: Set<String>): List<BookItem> {
|
||||
if (pinnedBookIds.isEmpty()) return this
|
||||
return withIndex()
|
||||
.sortedWith(
|
||||
compareByDescending<IndexedValue<BookItem>> { it.value.id in pinnedBookIds }
|
||||
.thenBy { it.index }
|
||||
)
|
||||
.map { it.value }
|
||||
}
|
||||
|
|
@ -0,0 +1,641 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderBookmark
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
|
||||
const val LOCAL_FOLDER_SYNC_DATA_DIR = "EpistemeSyncData"
|
||||
const val LOCAL_FOLDER_ANNOTATION_SUFFIX = "_annotations"
|
||||
const val LOCAL_FOLDER_SIDECAR_HASH_PREFIX = "book_"
|
||||
|
||||
fun localFolderSyncSha256ShortHex(value: String): String {
|
||||
val bytes = java.security.MessageDigest.getInstance("SHA-256").digest(value.toByteArray())
|
||||
return bytes.joinToString("") { "%02x".format(it) }.take(12)
|
||||
}
|
||||
|
||||
fun localFolderSyncSidecarStem(bookId: String): String {
|
||||
return LOCAL_FOLDER_SIDECAR_HASH_PREFIX + localFolderSyncSha256ShortHex(bookId)
|
||||
}
|
||||
|
||||
fun localFolderSyncMetadataFileName(bookId: String): String {
|
||||
return ".${localFolderSyncSidecarStem(bookId)}.json"
|
||||
}
|
||||
|
||||
fun localFolderSyncMetadataTempFileName(bookId: String): String {
|
||||
return ".${localFolderSyncSidecarStem(bookId)}.tmp"
|
||||
}
|
||||
|
||||
fun localFolderSyncAnnotationFileName(bookId: String): String {
|
||||
return ".${localFolderSyncSidecarStem(bookId)}$LOCAL_FOLDER_ANNOTATION_SUFFIX.json"
|
||||
}
|
||||
|
||||
fun localFolderSyncAnnotationTempFileName(bookId: String): String {
|
||||
return ".${localFolderSyncSidecarStem(bookId)}$LOCAL_FOLDER_ANNOTATION_SUFFIX.tmp"
|
||||
}
|
||||
|
||||
data class SharedFolderBookMetadata(
|
||||
val bookId: String,
|
||||
val title: String? = null,
|
||||
val author: String? = null,
|
||||
val displayName: String,
|
||||
val type: String,
|
||||
val lastChapterIndex: Int?,
|
||||
val lastPage: Int?,
|
||||
val lastPositionCfi: String?,
|
||||
val progressPercentage: Float,
|
||||
val isRecent: Boolean,
|
||||
val lastModifiedTimestamp: Long,
|
||||
val bookmarksJson: String?,
|
||||
val locatorBlockIndex: Int?,
|
||||
val locatorCharOffset: Int?,
|
||||
val customName: String?,
|
||||
val highlightsJson: String?,
|
||||
val seriesName: String? = null,
|
||||
val seriesIndex: Double? = null,
|
||||
val description: String? = null,
|
||||
val originalTitle: String? = null,
|
||||
val originalAuthor: String? = null,
|
||||
val originalSeriesName: String? = null,
|
||||
val originalSeriesIndex: Double? = null,
|
||||
val originalDescription: String? = null
|
||||
) {
|
||||
fun toJsonString(): String {
|
||||
return folderSyncJson.encodeToString(
|
||||
JsonElement.serializer(),
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"bookId" to JsonPrimitive(bookId),
|
||||
"displayName" to JsonPrimitive(displayName),
|
||||
"type" to JsonPrimitive(type),
|
||||
"lastChapterIndex" to JsonPrimitive(lastChapterIndex ?: -1),
|
||||
"lastPage" to JsonPrimitive(lastPage ?: -1),
|
||||
"lastPositionCfi" to lastPositionCfi.asJson(),
|
||||
"progressPercentage" to JsonPrimitive(progressPercentage.toDouble()),
|
||||
"isRecent" to JsonPrimitive(isRecent),
|
||||
"lastModifiedTimestamp" to JsonPrimitive(lastModifiedTimestamp),
|
||||
"bookmarksJson" to bookmarksJson.asJson(),
|
||||
"locatorBlockIndex" to JsonPrimitive(locatorBlockIndex ?: -1),
|
||||
"locatorCharOffset" to JsonPrimitive(locatorCharOffset ?: -1),
|
||||
"customName" to customName.asJson(),
|
||||
"highlightsJson" to highlightsJson.asJson()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun toBookItem(
|
||||
file: SharedFolderScannedFile,
|
||||
existing: BookItem? = null,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): BookItem {
|
||||
val parsedHighlights = highlightsJson
|
||||
?.let(EpubAnnotationSerializer::parseHighlightsJson)
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
val parsedBookmarks = parseReaderBookmarks(bookId)
|
||||
.takeIf { it.isNotEmpty() }
|
||||
val parsedType = runCatching { FileType.valueOf(type) }.getOrNull() ?: file.type
|
||||
val metadataTimestamp = lastModifiedTimestamp.takeIf { it > 0L } ?: nowMillis
|
||||
val parsedReaderPosition = readerPositionOrNull()
|
||||
|
||||
return (existing ?: BookItem(
|
||||
id = bookId,
|
||||
path = file.path,
|
||||
type = parsedType,
|
||||
displayName = displayName.ifBlank { file.name },
|
||||
timestamp = metadataTimestamp,
|
||||
title = file.name.substringBeforeLast('.', missingDelimiterValue = file.name),
|
||||
fileSize = file.size,
|
||||
fileContentModifiedTimestamp = file.lastModified,
|
||||
sourceFolder = file.sourceFolder,
|
||||
isRecent = isRecent
|
||||
)).copy(
|
||||
id = bookId,
|
||||
path = file.path,
|
||||
type = parsedType,
|
||||
displayName = displayName.ifBlank { file.name },
|
||||
timestamp = if (isRecent || existing == null) metadataTimestamp else existing.timestamp,
|
||||
coverImagePath = existing?.coverImagePath,
|
||||
title = existing?.title ?: file.name.substringBeforeLast('.', missingDelimiterValue = file.name),
|
||||
author = existing?.author,
|
||||
description = existing?.description,
|
||||
originalTitle = existing?.originalTitle,
|
||||
originalAuthor = existing?.originalAuthor,
|
||||
originalSeriesName = existing?.originalSeriesName,
|
||||
originalSeriesIndex = existing?.originalSeriesIndex,
|
||||
originalDescription = existing?.originalDescription,
|
||||
progressPercentage = progressPercentage,
|
||||
isRecent = isRecent || (existing?.isRecent ?: false),
|
||||
fileSize = file.size.takeIf { it > 0L } ?: existing?.fileSize ?: 0L,
|
||||
fileContentModifiedTimestamp = file.lastModified.takeIf { it > 0L } ?: existing?.fileContentModifiedTimestamp ?: 0L,
|
||||
sourceFolder = file.sourceFolder,
|
||||
folderTextMetadataParsed = existing?.folderTextMetadataParsed ?: false,
|
||||
seriesName = existing?.seriesName,
|
||||
seriesIndex = existing?.seriesIndex,
|
||||
lastPageIndex = lastPage,
|
||||
readerPosition = parsedReaderPosition ?: existing?.readerPosition,
|
||||
readingPositionModifiedTimestamp = if (
|
||||
parsedReaderPosition != null || lastPage != null || progressPercentage > 0f
|
||||
) {
|
||||
metadataTimestamp
|
||||
} else {
|
||||
existing?.readingPositionModifiedTimestamp ?: 0L
|
||||
},
|
||||
readerBookmarks = parsedBookmarks ?: existing?.readerBookmarks.orEmpty(),
|
||||
readerHighlights = parsedHighlights ?: existing?.readerHighlights.orEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
private fun readerPositionOrNull(): ReaderLocator? {
|
||||
if (lastChapterIndex == null && lastPage == null && lastPositionCfi.isNullOrBlank()) return null
|
||||
return ReaderLocator.fromLegacy(
|
||||
chapterIndex = lastChapterIndex,
|
||||
cfi = lastPositionCfi,
|
||||
pageIndex = lastPage
|
||||
).withFallbacks(
|
||||
blockIndex = locatorBlockIndex,
|
||||
charOffset = locatorCharOffset
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseReaderBookmarks(bookId: String): List<ReaderBookmark> {
|
||||
return EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson)
|
||||
.mapIndexed { index, bookmark ->
|
||||
val locator = bookmark.locator.withFallbacks(
|
||||
chapterIndex = bookmark.chapterIndex,
|
||||
cfi = bookmark.cfi,
|
||||
pageIndex = bookmark.pageInChapter?.minus(1),
|
||||
textQuote = bookmark.snippet
|
||||
)
|
||||
val pageIndex = locator.pageIndex ?: bookmark.pageInChapter?.minus(1) ?: 0
|
||||
ReaderBookmark(
|
||||
id = "bookmark_${localFolderSyncSha256ShortHex("$bookId:$index:${bookmark.cfi}")}",
|
||||
pageIndex = pageIndex.coerceAtLeast(0),
|
||||
chapterTitle = bookmark.chapterTitle,
|
||||
preview = bookmark.snippet,
|
||||
locator = locator
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromJsonString(rawJson: String): SharedFolderBookMetadata? {
|
||||
val obj = runCatching { folderSyncJson.parseToJsonElement(rawJson).jsonObject }.getOrNull()
|
||||
?: return null
|
||||
val bookId = obj.string("bookId")?.takeIf { it.isNotBlank() } ?: return null
|
||||
return SharedFolderBookMetadata(
|
||||
bookId = bookId,
|
||||
title = null,
|
||||
author = null,
|
||||
displayName = obj.string("displayName") ?: "Unknown",
|
||||
type = obj.string("type") ?: FileType.PDF.name,
|
||||
lastChapterIndex = obj.sentinelInt("lastChapterIndex"),
|
||||
lastPage = obj.sentinelInt("lastPage"),
|
||||
lastPositionCfi = obj.string("lastPositionCfi"),
|
||||
progressPercentage = obj.double("progressPercentage")?.toFloat() ?: 0f,
|
||||
isRecent = obj.boolean("isRecent") ?: true,
|
||||
lastModifiedTimestamp = obj.long("lastModifiedTimestamp") ?: 0L,
|
||||
bookmarksJson = obj.string("bookmarksJson"),
|
||||
locatorBlockIndex = obj.sentinelInt("locatorBlockIndex"),
|
||||
locatorCharOffset = obj.sentinelInt("locatorCharOffset"),
|
||||
customName = obj.string("customName"),
|
||||
highlightsJson = obj.string("highlightsJson"),
|
||||
seriesName = null,
|
||||
seriesIndex = null,
|
||||
description = null,
|
||||
originalTitle = null,
|
||||
originalAuthor = null,
|
||||
originalSeriesName = null,
|
||||
originalSeriesIndex = null,
|
||||
originalDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class SharedFolderScannedFile(
|
||||
val name: String,
|
||||
val path: String,
|
||||
val sourceFolder: String,
|
||||
val relativePath: String,
|
||||
val type: FileType,
|
||||
val size: Long,
|
||||
val lastModified: Long
|
||||
) {
|
||||
val stableBookId: String
|
||||
get() = LocalFolderSyncEngine.buildStableBookId(name, relativePath)
|
||||
}
|
||||
|
||||
data class LocalFolderSyncStats(
|
||||
val scannedFiles: Int = 0,
|
||||
val supportedFiles: Int = 0,
|
||||
val newBooks: Int = 0,
|
||||
val updatedBooks: Int = 0,
|
||||
val unchangedBooks: Int = 0,
|
||||
val removedBooks: Int = 0,
|
||||
val migratedBooks: Int = 0,
|
||||
val remoteMetadataUpdates: Int = 0
|
||||
) {
|
||||
operator fun plus(other: LocalFolderSyncStats): LocalFolderSyncStats {
|
||||
return LocalFolderSyncStats(
|
||||
scannedFiles = scannedFiles + other.scannedFiles,
|
||||
supportedFiles = supportedFiles + other.supportedFiles,
|
||||
newBooks = newBooks + other.newBooks,
|
||||
updatedBooks = updatedBooks + other.updatedBooks,
|
||||
unchangedBooks = unchangedBooks + other.unchangedBooks,
|
||||
removedBooks = removedBooks + other.removedBooks,
|
||||
migratedBooks = migratedBooks + other.migratedBooks,
|
||||
remoteMetadataUpdates = remoteMetadataUpdates + other.remoteMetadataUpdates
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class LocalFolderSyncResult(
|
||||
val state: SharedReaderScreenState,
|
||||
val idMigrations: Map<String, String>,
|
||||
val removedBookIds: Set<String>,
|
||||
val stats: LocalFolderSyncStats
|
||||
)
|
||||
|
||||
object LocalFolderSyncEngine {
|
||||
fun buildStableBookId(name: String, relativePath: String): String {
|
||||
val normalizedRelativePath = relativePath.toSyncRelativePath().ifBlank { name }
|
||||
return if (normalizedRelativePath.equals(name, ignoreCase = true)) {
|
||||
"local_$name"
|
||||
} else {
|
||||
"local_${name}_${localFolderSyncSha256ShortHex(normalizedRelativePath.lowercase())}"
|
||||
}
|
||||
}
|
||||
|
||||
fun syncFolder(
|
||||
state: SharedReaderScreenState,
|
||||
folder: SyncedFolder,
|
||||
files: List<SharedFolderScannedFile>,
|
||||
remoteMetadata: Map<String, SharedFolderBookMetadata>,
|
||||
nowMillis: Long = currentTimestamp(),
|
||||
metadataOnly: Boolean = false
|
||||
): LocalFolderSyncResult {
|
||||
if (!folder.localSyncEnabled) {
|
||||
return LocalFolderSyncResult(
|
||||
state = state,
|
||||
idMigrations = emptyMap(),
|
||||
removedBookIds = emptySet(),
|
||||
stats = LocalFolderSyncStats()
|
||||
)
|
||||
}
|
||||
|
||||
val folderRoot = folder.uriString
|
||||
val allowedTypes = folder.allowedFileTypes
|
||||
val booksById = linkedMapOf<String, BookItem>()
|
||||
state.rawLibraryBooks.forEach { booksById[it.id] = it }
|
||||
val idMigrations = linkedMapOf<String, String>()
|
||||
var stats = LocalFolderSyncStats(
|
||||
scannedFiles = files.size,
|
||||
supportedFiles = files.count { it.type in allowedTypes }
|
||||
)
|
||||
var removedIds = emptySet<String>()
|
||||
|
||||
val existingFolderBookIds = booksById.values
|
||||
.filter { it.sourceFolder == folderRoot }
|
||||
.mapTo(linkedSetOf()) { it.id }
|
||||
|
||||
remoteMetadata.forEach { (bookId, metadata) ->
|
||||
val existing = booksById[bookId]?.takeIf { it.sourceFolder == folderRoot }
|
||||
if (existing != null && metadata.lastModifiedTimestamp > existing.localFolderModifiedTimestamp()) {
|
||||
booksById[bookId] = existing.withAppliedFolderMetadata(metadata, nowMillis)
|
||||
stats = stats.copy(remoteMetadataUpdates = stats.remoteMetadataUpdates + 1)
|
||||
}
|
||||
}
|
||||
|
||||
if (!metadataOnly) {
|
||||
val foundBookIds = linkedSetOf<String>()
|
||||
val folderBooksByPath = booksById.values
|
||||
.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 }
|
||||
.filter { it.id.startsWith("local_${it.displayName}_") || it.id == it.path }
|
||||
.groupBy { it.displayName }
|
||||
.mapValues { (_, books) -> ArrayDeque<BookItem>().apply { addAll(books) } }
|
||||
.toMutableMap()
|
||||
|
||||
files
|
||||
.asSequence()
|
||||
.filter { it.type in allowedTypes }
|
||||
.sortedBy { it.relativePath.lowercase() }
|
||||
.forEach { file ->
|
||||
val stableId = file.stableBookId
|
||||
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 }
|
||||
if (migrated != null) {
|
||||
val oldId = migrated.id
|
||||
val migratedBook = migrated.copy(id = stableId).withScannedFile(file)
|
||||
booksById.remove(oldId)
|
||||
booksById[stableId] = migratedBook
|
||||
idMigrations[oldId] = stableId
|
||||
legacyItemsByName[file.name]?.remove(migrated)
|
||||
existing = migratedBook
|
||||
stats = stats.copy(migratedBooks = stats.migratedBooks + 1)
|
||||
}
|
||||
}
|
||||
|
||||
val metadata = remoteMetadata[stableId]
|
||||
if (existing == null) {
|
||||
booksById[stableId] = metadata?.toBookItem(file, nowMillis = nowMillis)
|
||||
?: file.toBookItem(stableId, nowMillis)
|
||||
stats = stats.copy(newBooks = stats.newBooks + 1)
|
||||
} else {
|
||||
val updatedForFile = existing.withScannedFile(file)
|
||||
val updated = metadata
|
||||
?.takeIf {
|
||||
it.lastModifiedTimestamp > 0L &&
|
||||
it.lastModifiedTimestamp >= updatedForFile.localFolderModifiedTimestamp()
|
||||
}
|
||||
?.toBookItem(file = file, existing = updatedForFile, nowMillis = nowMillis)
|
||||
?: updatedForFile
|
||||
booksById[stableId] = updated
|
||||
if (updated != existing) {
|
||||
stats = stats.copy(updatedBooks = stats.updatedBooks + 1)
|
||||
} else {
|
||||
stats = stats.copy(unchangedBooks = stats.unchangedBooks + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removedIds = existingFolderBookIds
|
||||
.map { idMigrations[it] ?: it }
|
||||
.filter { it !in foundBookIds }
|
||||
.toSet()
|
||||
removedIds.forEach(booksById::remove)
|
||||
stats = stats.copy(removedBooks = removedIds.size)
|
||||
}
|
||||
|
||||
val syncedFolder = folder.copy(lastScanTime = nowMillis)
|
||||
val syncedFolders = (state.syncedFolders.filterNot { it.uriString == folderRoot } + syncedFolder)
|
||||
.sortedBy { it.name.lowercase() }
|
||||
val migratedState = state
|
||||
.withMigratedBookIds(idMigrations)
|
||||
val nextState = migratedState
|
||||
.withoutBookIds(removedIds)
|
||||
.copy(
|
||||
rawLibraryBooks = booksById.values.toList(),
|
||||
syncedFolders = syncedFolders,
|
||||
lastFolderScanTime = nowMillis
|
||||
)
|
||||
|
||||
return LocalFolderSyncResult(
|
||||
state = nextState,
|
||||
idMigrations = idMigrations,
|
||||
removedBookIds = removedIds,
|
||||
stats = stats
|
||||
)
|
||||
}
|
||||
|
||||
fun applyIdMigrationsToShelfRefs(
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
migrations: Map<String, String>
|
||||
): List<BookShelfRef> {
|
||||
if (migrations.isEmpty()) return shelfRefs
|
||||
return shelfRefs.map { ref ->
|
||||
migrations[ref.bookId]?.let { ref.copy(bookId = it) } ?: ref
|
||||
}.distinctBy { it.bookId to it.shelfId }
|
||||
}
|
||||
}
|
||||
|
||||
fun BookItem.toSharedFolderBookMetadata(): SharedFolderBookMetadata? {
|
||||
if (sourceFolder.isNullOrBlank()) return null
|
||||
|
||||
val bookmarksJson = readerBookmarks
|
||||
.mapNotNull { it.toEpubBookmarkOrNull() }
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let(EpubAnnotationSerializer::bookmarksToJson)
|
||||
val highlightsJson = readerHighlights
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let(EpubAnnotationSerializer::highlightsToJson)
|
||||
val position = readerPosition
|
||||
val hasProgress = (progressPercentage ?: 0f) > 0f || lastPageIndex != null || position != null
|
||||
val isDirty = isRecent ||
|
||||
hasProgress ||
|
||||
!bookmarksJson.isNullOrBlank() ||
|
||||
!highlightsJson.isNullOrBlank()
|
||||
if (!isDirty) return null
|
||||
val positionCfi = position?.toStablePositionCfi()
|
||||
|
||||
return SharedFolderBookMetadata(
|
||||
bookId = id,
|
||||
title = null,
|
||||
author = null,
|
||||
displayName = displayName,
|
||||
type = type.name,
|
||||
lastChapterIndex = position?.chapterIndex,
|
||||
lastPage = position?.pageIndex ?: lastPageIndex,
|
||||
lastPositionCfi = positionCfi,
|
||||
progressPercentage = progressPercentage ?: 0f,
|
||||
isRecent = isRecent,
|
||||
lastModifiedTimestamp = localFolderModifiedTimestamp(),
|
||||
bookmarksJson = bookmarksJson,
|
||||
locatorBlockIndex = position?.blockIndex,
|
||||
locatorCharOffset = position?.charOffset,
|
||||
customName = null,
|
||||
highlightsJson = highlightsJson,
|
||||
seriesName = null,
|
||||
seriesIndex = null,
|
||||
description = null,
|
||||
originalTitle = null,
|
||||
originalAuthor = null,
|
||||
originalSeriesName = null,
|
||||
originalSeriesIndex = null,
|
||||
originalDescription = null
|
||||
)
|
||||
}
|
||||
|
||||
private val folderSyncJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private fun BookItem.withAppliedFolderMetadata(
|
||||
metadata: SharedFolderBookMetadata,
|
||||
nowMillis: Long
|
||||
): BookItem {
|
||||
val file = SharedFolderScannedFile(
|
||||
name = displayName,
|
||||
path = path.orEmpty(),
|
||||
sourceFolder = sourceFolder.orEmpty(),
|
||||
relativePath = displayName,
|
||||
type = runCatching { FileType.valueOf(metadata.type) }.getOrNull() ?: type,
|
||||
size = fileSize,
|
||||
lastModified = 0L
|
||||
)
|
||||
return metadata.toBookItem(file = file, existing = this, nowMillis = nowMillis)
|
||||
}
|
||||
|
||||
private fun SharedFolderScannedFile.toBookItem(bookId: String, nowMillis: Long): BookItem {
|
||||
return BookItem(
|
||||
id = bookId,
|
||||
path = path,
|
||||
type = type,
|
||||
displayName = name,
|
||||
timestamp = nowMillis,
|
||||
title = name.substringBeforeLast('.', missingDelimiterValue = name),
|
||||
fileSize = size,
|
||||
fileContentModifiedTimestamp = lastModified,
|
||||
sourceFolder = sourceFolder,
|
||||
isRecent = false
|
||||
)
|
||||
}
|
||||
|
||||
private fun BookItem.withScannedFile(file: SharedFolderScannedFile): BookItem {
|
||||
val sizeChanged = fileSize > 0L && file.size > 0L && fileSize != file.size
|
||||
val modifiedChanged = file.lastModified > 0L &&
|
||||
file.lastModified != fileContentModifiedTimestamp
|
||||
val contentChanged = sizeChanged || modifiedChanged
|
||||
return copy(
|
||||
path = file.path,
|
||||
type = file.type,
|
||||
displayName = file.name,
|
||||
coverImagePath = if (contentChanged) null else coverImagePath,
|
||||
title = if (contentChanged) file.name.substringBeforeLast('.', missingDelimiterValue = file.name) else title,
|
||||
author = if (contentChanged) null else author,
|
||||
description = if (contentChanged) null else description,
|
||||
originalTitle = if (contentChanged) null else originalTitle,
|
||||
originalAuthor = if (contentChanged) null else originalAuthor,
|
||||
originalSeriesName = if (contentChanged) null else originalSeriesName,
|
||||
originalSeriesIndex = if (contentChanged) null else originalSeriesIndex,
|
||||
originalDescription = if (contentChanged) null else originalDescription,
|
||||
seriesName = if (contentChanged) null else seriesName,
|
||||
seriesIndex = if (contentChanged) null else seriesIndex,
|
||||
fileSize = file.size.takeIf { it > 0L } ?: fileSize,
|
||||
fileContentModifiedTimestamp = file.lastModified.takeIf { it > 0L } ?: fileContentModifiedTimestamp,
|
||||
sourceFolder = file.sourceFolder,
|
||||
folderTextMetadataParsed = if (contentChanged) false else folderTextMetadataParsed
|
||||
)
|
||||
}
|
||||
|
||||
private fun BookItem.localFolderModifiedTimestamp(): Long {
|
||||
return timestamp
|
||||
}
|
||||
|
||||
private fun ReaderBookmark.toEpubBookmarkOrNull(): EpubBookmark? {
|
||||
val chapterIndex = locator.chapterIndex ?: 0
|
||||
val cfi = locator.cfi ?: "desktop:$chapterIndex:$pageIndex"
|
||||
return EpubBookmark(
|
||||
cfi = cfi,
|
||||
chapterTitle = chapterTitle,
|
||||
label = null,
|
||||
snippet = preview,
|
||||
pageInChapter = pageIndex + 1,
|
||||
totalPagesInChapter = null,
|
||||
chapterIndex = chapterIndex,
|
||||
locator = locator.withFallbacks(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
pageIndex = pageIndex,
|
||||
textQuote = preview
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun SharedReaderScreenState.withMigratedBookIds(
|
||||
migrations: Map<String, String>
|
||||
): SharedReaderScreenState {
|
||||
if (migrations.isEmpty()) return this
|
||||
|
||||
fun String.migrated(): String = migrations[this] ?: this
|
||||
fun Set<String>.migrated(): Set<String> = mapTo(linkedSetOf()) { it.migrated() }
|
||||
|
||||
return copy(
|
||||
selectedBookIds = selectedBookIds.migrated(),
|
||||
booksSelectedForAdding = booksSelectedForAdding.migrated(),
|
||||
pinnedHomeBookIds = pinnedHomeBookIds.migrated(),
|
||||
pinnedLibraryBookIds = pinnedLibraryBookIds.migrated(),
|
||||
openTabIds = openTabIds.map { it.migrated() }.distinct(),
|
||||
activeTabBookId = activeTabBookId?.migrated(),
|
||||
selectedBookId = selectedBookId?.migrated()
|
||||
)
|
||||
}
|
||||
|
||||
private fun SharedReaderScreenState.withoutBookIds(bookIds: Set<String>): SharedReaderScreenState {
|
||||
if (bookIds.isEmpty()) return this
|
||||
return copy(
|
||||
selectedBookIds = selectedBookIds - bookIds,
|
||||
booksSelectedForAdding = booksSelectedForAdding - bookIds,
|
||||
pinnedHomeBookIds = pinnedHomeBookIds - bookIds,
|
||||
pinnedLibraryBookIds = pinnedLibraryBookIds - bookIds,
|
||||
openTabIds = openTabIds.filterNot { it in bookIds },
|
||||
activeTabBookId = activeTabBookId?.takeUnless { it in bookIds },
|
||||
selectedBookId = selectedBookId?.takeUnless { it in bookIds }
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.toSyncRelativePath(): String {
|
||||
return replace('\\', '/')
|
||||
.split('/')
|
||||
.filter { it.isNotBlank() && it != "." }
|
||||
.joinToString("/")
|
||||
}
|
||||
|
||||
private fun JsonObject.string(name: String): String? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.long(name: String): Long? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.longOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.double(name: String): Double? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.doubleOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.boolean(name: String): Boolean? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.booleanOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.sentinelInt(name: String): Int? {
|
||||
val value = runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull }.getOrNull()
|
||||
return value?.takeUnless { it == -1 }
|
||||
}
|
||||
|
||||
private fun String?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
enum class SaveMode {
|
||||
ORIGINAL,
|
||||
ANNOTATED
|
||||
}
|
||||
|
||||
enum class SearchHighlightMode {
|
||||
FOCUSED,
|
||||
ALL
|
||||
}
|
||||
|
||||
enum class DockLocation {
|
||||
TOP,
|
||||
BOTTOM,
|
||||
FLOATING
|
||||
}
|
||||
|
||||
enum class PdfDisplayMode {
|
||||
PAGINATION,
|
||||
VERTICAL_SCROLL
|
||||
}
|
||||
|
||||
data class PdfTocEntry(
|
||||
val title: String,
|
||||
val pageIndex: Int,
|
||||
val nestLevel: Int
|
||||
)
|
||||
|
||||
data class PdfLink(
|
||||
val uri: String?,
|
||||
val destPageIndex: Int?,
|
||||
val bounds: PageRect
|
||||
)
|
||||
|
||||
data class PagePoint(
|
||||
val x: Float,
|
||||
val y: Float
|
||||
)
|
||||
|
||||
data class PageRect(
|
||||
val left: Float,
|
||||
val top: Float,
|
||||
val right: Float,
|
||||
val bottom: Float
|
||||
)
|
||||
|
||||
data class PdfTextRect(
|
||||
val rect: PageRect
|
||||
)
|
||||
|
||||
interface SharedReaderDocument : AutoCloseable {
|
||||
suspend fun getPageCount(): Int
|
||||
suspend fun openPage(pageIndex: Int): SharedReaderPage?
|
||||
suspend fun getTableOfContents(): List<PdfTocEntry>
|
||||
}
|
||||
|
||||
interface SharedReaderPage : AutoCloseable {
|
||||
suspend fun getPageWidthPoint(): Int
|
||||
suspend fun getPageHeightPoint(): Int
|
||||
suspend fun getPageRotation(): Int
|
||||
suspend fun openTextPage(): SharedReaderTextPage
|
||||
suspend fun getLinks(): List<PdfLink>
|
||||
}
|
||||
|
||||
interface SharedReaderTextPage : AutoCloseable {
|
||||
suspend fun textPageCountChars(): Int
|
||||
suspend fun textPageGetText(startIndex: Int, count: Int): String?
|
||||
suspend fun textPageGetRectsForRanges(ranges: IntArray): List<PdfTextRect>?
|
||||
suspend fun textPageGetCharIndexAtPos(x: Double, y: Double, xTolerance: Double, yTolerance: Double): Int
|
||||
suspend fun textPageGetCharBox(index: Int): PageRect?
|
||||
suspend fun textPageGetUnicode(index: Int): Int
|
||||
suspend fun loadWebLink(): SharedReaderWebLinks?
|
||||
}
|
||||
|
||||
interface SharedReaderWebLinks : AutoCloseable {
|
||||
suspend fun countWebLinks(): Int
|
||||
suspend fun getURL(linkIndex: Int, maxLength: Int): String?
|
||||
suspend fun countRects(linkIndex: Int): Int
|
||||
suspend fun getRect(linkIndex: Int, rectIndex: Int): PageRect
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
fun currentTimestamp(): Long = System.currentTimeMillis()
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
data class EpubBookmark(
|
||||
val cfi: String,
|
||||
val chapterTitle: String,
|
||||
val label: String? = null,
|
||||
val snippet: String,
|
||||
val pageInChapter: Int?,
|
||||
val totalPagesInChapter: Int?,
|
||||
val chapterIndex: Int,
|
||||
val locator: ReaderLocator = ReaderLocator.fromLegacy(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
pageIndex = pageInChapter?.minus(1),
|
||||
textQuote = snippet
|
||||
)
|
||||
)
|
||||
|
||||
enum class HighlightColor(val id: String, val color: Color, val cssClass: String) {
|
||||
YELLOW("yellow", Color(0xFFFBC02D), "user-highlight-yellow"),
|
||||
GREEN("green", Color(0xFF388E3C), "user-highlight-green"),
|
||||
BLUE("blue", Color(0xFF1976D2), "user-highlight-blue"),
|
||||
RED("red", Color(0xFFD32F2F), "user-highlight-red"),
|
||||
PURPLE("purple", Color(0xFF7B1FA2), "user-highlight-purple"),
|
||||
ORANGE("orange", Color(0xFFF57C00), "user-highlight-orange"),
|
||||
CYAN("cyan", Color(0xFF0097A7), "user-highlight-cyan"),
|
||||
MAGENTA("magenta", Color(0xFFC2185B), "user-highlight-magenta"),
|
||||
LIME("lime", Color(0xFFAFB42B), "user-highlight-lime"),
|
||||
PINK("pink", Color(0xFFE91E63), "user-highlight-pink"),
|
||||
TEAL("teal", Color(0xFF00796B), "user-highlight-teal"),
|
||||
INDIGO("indigo", Color(0xFF303F9F), "user-highlight-indigo"),
|
||||
BLACK("black", Color(0xFF424242), "user-highlight-black"),
|
||||
WHITE("white", Color(0xFFF5F5F5), "user-highlight-white")
|
||||
}
|
||||
|
||||
data class ReaderLocator(
|
||||
val chapterIndex: Int? = null,
|
||||
val chapterId: String? = null,
|
||||
val href: String? = null,
|
||||
val pageIndex: Int? = null,
|
||||
val startOffset: Int? = null,
|
||||
val endOffset: Int? = null,
|
||||
val blockIndex: Int? = null,
|
||||
val charOffset: Int? = null,
|
||||
val textQuote: String? = null,
|
||||
val cfi: String? = null
|
||||
) {
|
||||
val hasTextRange: Boolean
|
||||
get() = startOffset != null && endOffset != null && endOffset >= startOffset
|
||||
val hasBlockPosition: Boolean
|
||||
get() = blockIndex != null && charOffset != null
|
||||
|
||||
fun withFallbacks(
|
||||
chapterIndex: Int? = null,
|
||||
chapterId: String? = null,
|
||||
href: String? = null,
|
||||
pageIndex: Int? = null,
|
||||
startOffset: Int? = null,
|
||||
endOffset: Int? = null,
|
||||
blockIndex: Int? = null,
|
||||
charOffset: Int? = null,
|
||||
textQuote: String? = null,
|
||||
cfi: String? = null
|
||||
): ReaderLocator {
|
||||
return copy(
|
||||
chapterIndex = this.chapterIndex ?: chapterIndex,
|
||||
chapterId = this.chapterId ?: chapterId,
|
||||
href = this.href ?: href,
|
||||
pageIndex = this.pageIndex ?: pageIndex,
|
||||
startOffset = this.startOffset ?: startOffset,
|
||||
endOffset = this.endOffset ?: endOffset,
|
||||
blockIndex = this.blockIndex ?: blockIndex,
|
||||
charOffset = this.charOffset ?: charOffset,
|
||||
textQuote = this.textQuote ?: textQuote,
|
||||
cfi = this.cfi ?: cfi
|
||||
)
|
||||
}
|
||||
|
||||
fun sameLocation(other: ReaderLocator): Boolean {
|
||||
val sameChapter = chapterIndex == null || other.chapterIndex == null || chapterIndex == other.chapterIndex
|
||||
if (!sameChapter) return false
|
||||
|
||||
if (hasBlockPosition && other.hasBlockPosition) {
|
||||
return blockIndex == other.blockIndex && charOffset == other.charOffset
|
||||
}
|
||||
|
||||
if (hasTextRange && other.hasTextRange) {
|
||||
return startOffset == other.startOffset && endOffset == other.endOffset
|
||||
}
|
||||
|
||||
if (pageIndex != null && other.pageIndex != null) {
|
||||
return pageIndex == other.pageIndex
|
||||
}
|
||||
|
||||
return cfi != null && cfi == other.cfi
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromLegacy(
|
||||
chapterIndex: Int? = null,
|
||||
cfi: String? = null,
|
||||
pageIndex: Int? = null,
|
||||
textQuote: String? = null
|
||||
): ReaderLocator {
|
||||
val stableCfi = cfi?.toStableReaderPositionCfi()
|
||||
val desktopParts = stableCfi
|
||||
?.takeIf { it.startsWith("desktop:") }
|
||||
?.split(':')
|
||||
.orEmpty()
|
||||
val parsedChapterIndex = desktopParts.getOrNull(1)?.toIntOrNull()
|
||||
val possibleStartOffset = desktopParts.getOrNull(2)?.toIntOrNull()
|
||||
val possibleEndOffset = desktopParts.getOrNull(3)?.toIntOrNull()
|
||||
val androidLocatorParts = stableCfi
|
||||
?.takeIf { it.startsWith("android-locator:") }
|
||||
?.split(':')
|
||||
.orEmpty()
|
||||
val parsedAndroidChapterIndex = androidLocatorParts.getOrNull(1)?.toIntOrNull()
|
||||
val parsedBlockIndex = androidLocatorParts.getOrNull(2)?.toIntOrNull()
|
||||
val parsedCharOffset = androidLocatorParts.getOrNull(3)?.toIntOrNull()
|
||||
?.takeIf { it >= 0 }
|
||||
val parsedAndroidEndOffset = parsedCharOffset
|
||||
?.let { start -> textQuote?.takeIf { it.isNotBlank() }?.let { start + it.length } }
|
||||
val hasOffsetRange = desktopParts.size == 4 &&
|
||||
possibleStartOffset != null &&
|
||||
possibleEndOffset != null &&
|
||||
possibleStartOffset >= 0 &&
|
||||
possibleEndOffset >= possibleStartOffset &&
|
||||
possibleEndOffset - possibleStartOffset <= 100_000
|
||||
val parsedStartOffset = when {
|
||||
hasOffsetRange -> possibleStartOffset
|
||||
parsedBlockIndex != null && parsedAndroidEndOffset != null -> parsedCharOffset
|
||||
else -> null
|
||||
}
|
||||
val parsedEndOffset = when {
|
||||
hasOffsetRange -> possibleEndOffset
|
||||
parsedBlockIndex != null -> parsedAndroidEndOffset
|
||||
else -> null
|
||||
}
|
||||
val parsedPageIndex = when {
|
||||
pageIndex != null -> pageIndex
|
||||
desktopParts.size == 3 || desktopParts.size >= 5 || (desktopParts.size == 4 && !hasOffsetRange) ->
|
||||
desktopParts.getOrNull(2)?.toIntOrNull()
|
||||
else -> null
|
||||
}
|
||||
return ReaderLocator(
|
||||
chapterIndex = chapterIndex ?: parsedChapterIndex ?: parsedAndroidChapterIndex,
|
||||
pageIndex = parsedPageIndex,
|
||||
startOffset = parsedStartOffset,
|
||||
endOffset = parsedEndOffset,
|
||||
blockIndex = parsedBlockIndex,
|
||||
charOffset = parsedCharOffset,
|
||||
textQuote = textQuote,
|
||||
cfi = stableCfi ?: cfi
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun String.toStableReaderPositionCfi(): String {
|
||||
val trimmed = trim()
|
||||
if (!trimmed.startsWith("desktop-scroll:")) return trimmed
|
||||
return trimmed
|
||||
.split(':', limit = 4)
|
||||
.getOrNull(3)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: trimmed
|
||||
}
|
||||
|
||||
fun ReaderLocator.toStablePositionCfi(): String? {
|
||||
cfi
|
||||
?.toStableReaderPositionCfi()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.takeUnless { it.startsWith("desktop-scroll:") || it.startsWith("desktop-scroll-page:") }
|
||||
?.let { return it }
|
||||
|
||||
val chapter = chapterIndex
|
||||
val start = startOffset
|
||||
val end = endOffset ?: start
|
||||
return when {
|
||||
chapter != null && blockIndex != null && charOffset != null ->
|
||||
"android-locator:$chapter:$blockIndex:$charOffset"
|
||||
chapter != null && start != null && end != null ->
|
||||
"desktop:$chapter:$start:$end"
|
||||
chapter != null && pageIndex != null ->
|
||||
"desktop:$chapter:$pageIndex"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
data class ReaderHighlightPalette(
|
||||
val colors: List<HighlightColor> = defaultColors
|
||||
) {
|
||||
fun sanitized(): ReaderHighlightPalette {
|
||||
val knownColors = colors.filter { it in HighlightColor.entries }
|
||||
return copy(colors = knownColors.takeIf { it.size == PaletteSize } ?: defaultColors)
|
||||
}
|
||||
|
||||
fun contains(color: HighlightColor): Boolean {
|
||||
return color in sanitized().colors
|
||||
}
|
||||
|
||||
fun withColor(color: HighlightColor, enabled: Boolean): ReaderHighlightPalette {
|
||||
val next = if (enabled) {
|
||||
colors + color
|
||||
} else {
|
||||
colors - color
|
||||
}
|
||||
return copy(colors = next).sanitized()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PaletteSize: Int = 4
|
||||
val defaultColors: List<HighlightColor>
|
||||
get() = listOf(
|
||||
HighlightColor.YELLOW,
|
||||
HighlightColor.GREEN,
|
||||
HighlightColor.BLUE,
|
||||
HighlightColor.RED
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class UserHighlight(
|
||||
val id: String,
|
||||
val cfi: String,
|
||||
val text: String,
|
||||
val color: HighlightColor,
|
||||
val chapterIndex: Int,
|
||||
val note: String? = null,
|
||||
val locator: ReaderLocator = ReaderLocator.fromLegacy(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
textQuote = text
|
||||
)
|
||||
)
|
||||
|
||||
fun escapeJsString(value: String): String {
|
||||
return value
|
||||
.replace("\\", "\\\\")
|
||||
.replace("'", "\\'")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t")
|
||||
.replace("\u2028", "\\u2028")
|
||||
.replace("\u2029", "\\u2029")
|
||||
}
|
||||
|
|
@ -0,0 +1,285 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
object EpubAnnotationSerializer {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
fun parseBookmarksJson(rawJson: String?, chapterTitles: List<String> = emptyList()): Set<EpubBookmark> {
|
||||
if (rawJson.isNullOrBlank()) return emptySet()
|
||||
val root = runCatching { json.parseToJsonElement(rawJson).jsonArray }.getOrNull() ?: return emptySet()
|
||||
return root.mapNotNull { element ->
|
||||
when (element) {
|
||||
is JsonObject -> element.asBookmarkOrNull(chapterTitles)
|
||||
else -> element.contentOrNull()
|
||||
?.let { rawBookmark -> parseBookmarkObject(rawBookmark, chapterTitles) }
|
||||
}
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
fun parseBookmarkEntries(entries: Collection<String>, chapterTitles: List<String> = emptyList()): Set<EpubBookmark> {
|
||||
return entries.mapNotNull { parseBookmarkObject(it, chapterTitles) }.toSet()
|
||||
}
|
||||
|
||||
fun bookmarksToJson(bookmarks: Collection<EpubBookmark>): String {
|
||||
val bookmarkEntries = bookmarks.map { JsonPrimitive(it.toJsonString()) }
|
||||
return json.encodeToString(JsonElement.serializer(), JsonArray(bookmarkEntries))
|
||||
}
|
||||
|
||||
fun parseHighlightsJson(rawJson: String?): List<UserHighlight> {
|
||||
if (rawJson.isNullOrBlank()) return emptyList()
|
||||
val root = runCatching { json.parseToJsonElement(rawJson).jsonArray }.getOrNull() ?: return emptyList()
|
||||
return root.mapNotNull { element ->
|
||||
runCatching { element.jsonObject.asHighlightOrNull() }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
fun parseHighlightJson(rawJson: String?): UserHighlight? {
|
||||
if (rawJson.isNullOrBlank()) return null
|
||||
return runCatching { json.parseToJsonElement(rawJson).jsonObject.asHighlightOrNull() }.getOrNull()
|
||||
}
|
||||
|
||||
fun parseHighlightJsonLenient(rawJson: String?): UserHighlight? {
|
||||
if (rawJson.isNullOrBlank()) return null
|
||||
val element = runCatching { json.parseToJsonElement(rawJson) }.getOrNull() ?: return null
|
||||
return element.asHighlightLenientOrNull()
|
||||
}
|
||||
|
||||
fun highlightsToJson(highlights: Collection<UserHighlight>): String {
|
||||
return json.encodeToString(
|
||||
JsonElement.serializer(),
|
||||
JsonArray(highlights.map { it.toJsonObject() })
|
||||
)
|
||||
}
|
||||
|
||||
fun processAndAddHighlight(
|
||||
newCfi: String,
|
||||
newText: String,
|
||||
newColor: HighlightColor,
|
||||
chapterIndex: Int,
|
||||
currentList: MutableList<UserHighlight>,
|
||||
locator: ReaderLocator = ReaderLocator.fromLegacy(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = newCfi,
|
||||
textQuote = newText
|
||||
)
|
||||
): String {
|
||||
val normalizedLocator = locator.withFallbacks(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = newCfi,
|
||||
textQuote = newText
|
||||
)
|
||||
val exactMatchIndex = currentList.indexOfFirst {
|
||||
it.chapterIndex == chapterIndex &&
|
||||
(it.cfi == newCfi || it.locator.sameLocation(normalizedLocator))
|
||||
}
|
||||
|
||||
if (exactMatchIndex != -1) {
|
||||
val existing = currentList[exactMatchIndex]
|
||||
currentList[exactMatchIndex] = existing.copy(
|
||||
cfi = newCfi,
|
||||
color = newColor,
|
||||
text = newText,
|
||||
locator = existing.locator.copy(cfi = newCfi, textQuote = newText).withFallbacks(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = newCfi,
|
||||
textQuote = newText
|
||||
)
|
||||
)
|
||||
return newCfi
|
||||
}
|
||||
|
||||
currentList.add(
|
||||
UserHighlight(
|
||||
id = stableHighlightId(newCfi, chapterIndex),
|
||||
cfi = newCfi,
|
||||
text = newText,
|
||||
color = newColor,
|
||||
chapterIndex = chapterIndex,
|
||||
note = null,
|
||||
locator = normalizedLocator
|
||||
)
|
||||
)
|
||||
return newCfi
|
||||
}
|
||||
|
||||
private fun parseBookmarkObject(rawJson: String, chapterTitles: List<String>): EpubBookmark? {
|
||||
return runCatching { json.parseToJsonElement(rawJson).jsonObject.asBookmarkOrNull(chapterTitles) }.getOrNull()
|
||||
}
|
||||
|
||||
private fun EpubBookmark.toJsonString(): String {
|
||||
return json.encodeToString(JsonElement.serializer(), toJsonObject())
|
||||
}
|
||||
|
||||
private fun EpubBookmark.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
buildMap {
|
||||
put("cfi", JsonPrimitive(cfi))
|
||||
put("chapterTitle", JsonPrimitive(chapterTitle))
|
||||
put("label", label.asJson())
|
||||
put("snippet", JsonPrimitive(snippet))
|
||||
pageInChapter?.let { put("pageInChapter", JsonPrimitive(it)) }
|
||||
totalPagesInChapter?.let { put("totalPagesInChapter", JsonPrimitive(it)) }
|
||||
put("chapterIndex", JsonPrimitive(chapterIndex))
|
||||
put("locator", locator.toJsonObject())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonObject.asBookmarkOrNull(chapterTitles: List<String>): EpubBookmark? {
|
||||
val cfi = string("cfi") ?: return null
|
||||
val chapterTitle = string("chapterTitle") ?: return null
|
||||
val chapterIndex = int("chapterIndex")
|
||||
?: chapterTitles.indexOfFirst { it == chapterTitle }.coerceAtLeast(0)
|
||||
return EpubBookmark(
|
||||
cfi = cfi,
|
||||
chapterTitle = chapterTitle,
|
||||
label = string("label"),
|
||||
snippet = string("snippet") ?: "",
|
||||
pageInChapter = int("pageInChapter"),
|
||||
totalPagesInChapter = int("totalPagesInChapter"),
|
||||
chapterIndex = chapterIndex,
|
||||
locator = this["locator"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderLocatorOrNull()
|
||||
?.withFallbacks(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
pageIndex = int("pageInChapter")?.minus(1),
|
||||
textQuote = string("snippet") ?: ""
|
||||
)
|
||||
?: ReaderLocator.fromLegacy(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
pageIndex = int("pageInChapter")?.minus(1),
|
||||
textQuote = string("snippet") ?: ""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonObject.asHighlightOrNull(): UserHighlight? {
|
||||
val cfi = string("cfi") ?: return null
|
||||
val text = string("text") ?: return null
|
||||
val chapterIndex = int("chapterIndex") ?: return null
|
||||
val colorId = string("colorId")
|
||||
val color = HighlightColor.entries.firstOrNull { it.id == colorId } ?: HighlightColor.YELLOW
|
||||
val note = string("note")?.takeIf { it.isNotBlank() }
|
||||
return UserHighlight(
|
||||
id = string("id")?.takeIf { it.isNotBlank() } ?: stableHighlightId(cfi, chapterIndex),
|
||||
cfi = cfi,
|
||||
text = text,
|
||||
color = color,
|
||||
chapterIndex = chapterIndex,
|
||||
note = note,
|
||||
locator = this["locator"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderLocatorOrNull()
|
||||
?.withFallbacks(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
textQuote = text
|
||||
)
|
||||
?: ReaderLocator.fromLegacy(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
textQuote = text
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asHighlightLenientOrNull(): UserHighlight? {
|
||||
return when (this) {
|
||||
is JsonObject -> asHighlightOrNull()
|
||||
is JsonArray -> {
|
||||
for (element in this) {
|
||||
element.asHighlightLenientOrNull()?.let { return it }
|
||||
}
|
||||
null
|
||||
}
|
||||
else -> contentOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.startsWith("{") || it.startsWith("[") }
|
||||
?.let { parseHighlightJsonLenient(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun UserHighlight.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"cfi" to JsonPrimitive(cfi),
|
||||
"text" to JsonPrimitive(text),
|
||||
"colorId" to JsonPrimitive(color.id),
|
||||
"chapterIndex" to JsonPrimitive(chapterIndex),
|
||||
"note" to (note ?: "").asJson(),
|
||||
"locator" to locator.toJsonObject()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderLocator.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
buildMap {
|
||||
chapterIndex?.let { put("chapterIndex", JsonPrimitive(it)) }
|
||||
chapterId?.let { put("chapterId", JsonPrimitive(it)) }
|
||||
href?.let { put("href", JsonPrimitive(it)) }
|
||||
pageIndex?.let { put("pageIndex", JsonPrimitive(it)) }
|
||||
startOffset?.let { put("startOffset", JsonPrimitive(it)) }
|
||||
endOffset?.let { put("endOffset", JsonPrimitive(it)) }
|
||||
blockIndex?.let { put("blockIndex", JsonPrimitive(it)) }
|
||||
charOffset?.let { put("charOffset", JsonPrimitive(it)) }
|
||||
textQuote?.let { put("textQuote", JsonPrimitive(it)) }
|
||||
cfi?.let { put("cfi", JsonPrimitive(it)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asReaderLocatorOrNull(): ReaderLocator? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return ReaderLocator(
|
||||
chapterIndex = obj.int("chapterIndex"),
|
||||
chapterId = obj.string("chapterId"),
|
||||
href = obj.string("href"),
|
||||
pageIndex = obj.int("pageIndex"),
|
||||
startOffset = obj.int("startOffset"),
|
||||
endOffset = obj.int("endOffset"),
|
||||
blockIndex = obj.int("blockIndex"),
|
||||
charOffset = obj.int("charOffset"),
|
||||
textQuote = obj.string("textQuote"),
|
||||
cfi = obj.string("cfi")
|
||||
)
|
||||
}
|
||||
|
||||
private fun stableHighlightId(cfi: String, chapterIndex: Int): String {
|
||||
val key = "$chapterIndex:$cfi"
|
||||
var hash = 1125899906842597L
|
||||
key.forEach { char -> hash = 31 * hash + char.code }
|
||||
return "highlight_${hash.toString(16)}"
|
||||
}
|
||||
|
||||
private fun JsonObject.string(name: String): String? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.int(name: String): Int? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonElement.contentOrNull(): String? {
|
||||
return runCatching { takeUnless { it is JsonNull }?.jsonPrimitive?.content }.getOrNull()
|
||||
}
|
||||
|
||||
private fun String?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||
}
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderSettings
|
||||
import org.dueattendant149.bookreader.shared.reader.SharedReaderTextAlign
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
enum class ReaderFont(val id: String, val displayName: String, val fontFamilyName: String) {
|
||||
ORIGINAL("original", "Original", "Original"),
|
||||
MERRIWEATHER("merriweather", "Merriweather", "Merriweather"),
|
||||
LATO("lato", "Lato", "Lato"),
|
||||
LORA("lora", "Lora", "Lora"),
|
||||
ROBOTO_MONO("roboto_mono", "Roboto Mono", "Roboto Mono"),
|
||||
LEXEND("lexend", "Lexend", "Lexend")
|
||||
}
|
||||
|
||||
enum class ReaderTextAlign(val id: String, val cssValue: String, val displayName: String) {
|
||||
DEFAULT("default", "", "Default"),
|
||||
LEFT("left", "left", "Left"),
|
||||
RIGHT("right", "right", "Right"),
|
||||
JUSTIFY("justify", "justify", "Justify")
|
||||
}
|
||||
|
||||
enum class SystemUiMode(val id: Int, val title: String) {
|
||||
DEFAULT(0, "Always Show"),
|
||||
SYNC(1, "Sync with Menus"),
|
||||
HIDDEN(2, "Always Hide")
|
||||
}
|
||||
|
||||
enum class PageInfoMode(val id: Int, val title: String) {
|
||||
DEFAULT(0, "Always Show"),
|
||||
SYNC(1, "Sync with Menus"),
|
||||
HIDDEN(2, "Always Hide")
|
||||
}
|
||||
|
||||
enum class PageInfoPosition(val id: Int, val title: String) {
|
||||
BOTTOM(0, "Bottom"),
|
||||
TOP(1, "Top")
|
||||
}
|
||||
|
||||
data class FormatSettings(
|
||||
val fontSize: Float,
|
||||
val lineHeight: Float,
|
||||
val paragraphGap: Float,
|
||||
val imageSize: Float,
|
||||
val horizontalMargin: Float,
|
||||
val font: ReaderFont,
|
||||
val customPath: String?,
|
||||
val textAlign: ReaderTextAlign,
|
||||
val verticalMargin: Float = 1.0f
|
||||
)
|
||||
|
||||
enum class ReaderTexture(val id: String, val displayName: String, val assetPath: String) {
|
||||
NATURAL_WHITE("asset:ep_naturalwhite.webp", "Natural White", "textures/ep_naturalwhite.webp"),
|
||||
NATURAL_BLACK("asset:ep_naturalblack.webp", "Natural Black", "textures/ep_naturalblack.webp"),
|
||||
LIGHT_VENEER("asset:light-veneer.webp", "Light Veneer", "textures/light-veneer.webp"),
|
||||
RETINA_WOOD("asset:retina_wood.webp", "Retina Wood", "textures/retina_wood.webp"),
|
||||
GREY_WASH("asset:grey_wash_wall.webp", "Grey Wash", "textures/grey_wash_wall.webp"),
|
||||
CLASSY_FABRIC("asset:classy_fabric.webp", "Classy Fabric", "textures/classy_fabric.webp"),
|
||||
RETRO_INTRO("asset:retro_intro.webp", "Retro Intro", "textures/retro_intro.webp"),
|
||||
PAPER("paper", "Paper", "textures/texture_paper.png"),
|
||||
CANVAS("canvas", "Canvas", "textures/texture_canvas.png"),
|
||||
EINK("eink", "E-Ink", "textures/texture_eink.webp"),
|
||||
SLATE("slate", "Slate", "textures/texture_slate.png")
|
||||
}
|
||||
|
||||
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,
|
||||
val backgroundColor: Color,
|
||||
val textColor: Color,
|
||||
val isDark: Boolean,
|
||||
val textureId: String? = null,
|
||||
val isCustom: Boolean = false
|
||||
)
|
||||
|
||||
fun List<ReaderTheme>.sanitizeCustomReaderThemes(): List<ReaderTheme> {
|
||||
val seenIds = mutableSetOf<String>()
|
||||
return asReversed()
|
||||
.filter { theme ->
|
||||
theme.isCustom &&
|
||||
theme.id.isNotBlank() &&
|
||||
theme.name.isNotBlank() &&
|
||||
theme.backgroundColor.isSpecified &&
|
||||
theme.textColor.isSpecified &&
|
||||
seenIds.add(theme.id)
|
||||
}
|
||||
.asReversed()
|
||||
}
|
||||
|
||||
private val StandardReaderSolidThemes = listOf(
|
||||
ReaderTheme("light", "Light", Color(0xFFFFFFFF), Color(0xFF000000), false),
|
||||
ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true),
|
||||
ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false),
|
||||
ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true),
|
||||
ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true)
|
||||
)
|
||||
|
||||
private val StandardReaderTexturedThemes = listOf(
|
||||
ReaderTheme("natural_white_texture", "Natural White", Color(0xFFF7F1E5), Color(0xFF1D1B18), false, textureId = ReaderTexture.NATURAL_WHITE.id),
|
||||
ReaderTheme("retina_texture", "Retina", Color(0xFFF1E4CD), Color(0xFF2A2119), false, textureId = ReaderTexture.RETINA_WOOD.id),
|
||||
ReaderTheme("veneer_texture", "Veneer", Color(0xFFF4E7CF), Color(0xFF2A2119), false, textureId = ReaderTexture.LIGHT_VENEER.id),
|
||||
ReaderTheme("grey_wash_texture", "Grey Wash", Color(0xFF202124), Color(0xFFFFFFFF), true, textureId = ReaderTexture.GREY_WASH.id),
|
||||
ReaderTheme("fabric_texture", "Fabric", Color(0xFF262626), Color(0xFFE8E2D8), true, textureId = ReaderTexture.CLASSY_FABRIC.id),
|
||||
ReaderTheme("retro_texture", "Retro", Color(0xFFF6ECD8), Color(0xFF2F2118), false, textureId = ReaderTexture.RETRO_INTRO.id)
|
||||
)
|
||||
|
||||
val BuiltInReaderThemes = listOf(
|
||||
ReaderTheme("system", "System", Color.Unspecified, Color.Unspecified, false)
|
||||
) + StandardReaderSolidThemes + StandardReaderTexturedThemes
|
||||
|
||||
val BuiltInPdfReaderThemes = listOf(
|
||||
ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
|
||||
ReaderTheme("reverse", "Reverse", Color.Black, Color.White, true)
|
||||
) + StandardReaderSolidThemes + StandardReaderTexturedThemes.map { theme ->
|
||||
theme.copy(id = "pdf_${theme.id}")
|
||||
}
|
||||
|
||||
fun FormatSettings.toReaderSettings(base: ReaderSettings = ReaderSettings()): ReaderSettings {
|
||||
val horizontalMarginPx = (ReaderAppearanceDefaults.marginPx * horizontalMargin).roundToInt()
|
||||
.coerceIn(ReaderAppearanceDefaults.minMarginPx, ReaderAppearanceDefaults.maxMarginPx)
|
||||
val verticalMarginPx = (ReaderAppearanceDefaults.marginPx * verticalMargin).roundToInt()
|
||||
.coerceIn(ReaderAppearanceDefaults.minMarginPx, ReaderAppearanceDefaults.maxMarginPx)
|
||||
return base.copy(
|
||||
fontSize = (ReaderAppearanceDefaults.fontSizePx * fontSize).roundToInt()
|
||||
.coerceIn(ReaderAppearanceDefaults.minFontSizePx, ReaderAppearanceDefaults.maxFontSizePx),
|
||||
lineSpacing = (ReaderAppearanceDefaults.lineSpacing * lineHeight)
|
||||
.coerceIn(ReaderAppearanceDefaults.minLineSpacing, ReaderAppearanceDefaults.maxLineSpacing),
|
||||
margin = max(horizontalMarginPx, verticalMarginPx),
|
||||
horizontalMargin = horizontalMarginPx,
|
||||
verticalMargin = verticalMarginPx,
|
||||
textAlign = textAlign.toSharedReaderTextAlign(),
|
||||
fontFamily = customPath?.takeIf { it.isNotBlank() } ?: font.toReaderSettingsFontFamily(),
|
||||
customFontPath = customPath?.takeIf { it.isNotBlank() },
|
||||
paragraphSpacing = paragraphGap.coerceIn(
|
||||
ReaderAppearanceDefaults.minParagraphSpacing,
|
||||
ReaderAppearanceDefaults.maxParagraphSpacing
|
||||
),
|
||||
imageScale = imageSize.coerceIn(
|
||||
ReaderAppearanceDefaults.minImageScale,
|
||||
ReaderAppearanceDefaults.maxImageScale
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun ReaderTheme.toReaderSettings(base: ReaderSettings = ReaderSettings()): ReaderSettings {
|
||||
return base.copy(
|
||||
darkMode = isDark,
|
||||
themeId = id,
|
||||
textureId = textureId,
|
||||
backgroundColorArgb = backgroundColor.takeIf { it.isSpecified }?.toArgb()?.toLong(),
|
||||
textColorArgb = textColor.takeIf { it.isSpecified }?.toArgb()?.toLong()
|
||||
)
|
||||
}
|
||||
|
||||
fun ReaderSettings.resetReaderFormatSettings(): ReaderSettings {
|
||||
val defaults = ReaderSettings()
|
||||
return copy(
|
||||
fontSize = defaults.fontSize,
|
||||
lineSpacing = defaults.lineSpacing,
|
||||
margin = defaults.margin,
|
||||
horizontalMargin = defaults.horizontalMargin,
|
||||
verticalMargin = defaults.verticalMargin,
|
||||
textAlign = defaults.textAlign,
|
||||
pageWidth = defaults.pageWidth,
|
||||
fontFamily = defaults.fontFamily,
|
||||
paragraphSpacing = defaults.paragraphSpacing,
|
||||
imageScale = defaults.imageScale,
|
||||
customFontPath = defaults.customFontPath
|
||||
)
|
||||
}
|
||||
|
||||
fun ReaderSettings.withHorizontalReaderMargin(horizontalMarginPx: Int): ReaderSettings {
|
||||
val nextHorizontal = horizontalMarginPx.coerceIn(
|
||||
ReaderAppearanceDefaults.minMarginPx,
|
||||
ReaderAppearanceDefaults.maxMarginPx
|
||||
)
|
||||
val currentVertical = resolvedVerticalMargin.coerceIn(
|
||||
ReaderAppearanceDefaults.minMarginPx,
|
||||
ReaderAppearanceDefaults.maxMarginPx
|
||||
)
|
||||
return copy(
|
||||
margin = max(nextHorizontal, currentVertical),
|
||||
horizontalMargin = nextHorizontal,
|
||||
verticalMargin = currentVertical
|
||||
)
|
||||
}
|
||||
|
||||
fun ReaderSettings.withVerticalReaderMargin(verticalMarginPx: Int): ReaderSettings {
|
||||
val currentHorizontal = resolvedHorizontalMargin.coerceIn(
|
||||
ReaderAppearanceDefaults.minMarginPx,
|
||||
ReaderAppearanceDefaults.maxMarginPx
|
||||
)
|
||||
val nextVertical = verticalMarginPx.coerceIn(
|
||||
ReaderAppearanceDefaults.minMarginPx,
|
||||
ReaderAppearanceDefaults.maxMarginPx
|
||||
)
|
||||
return copy(
|
||||
margin = max(currentHorizontal, nextVertical),
|
||||
horizontalMargin = currentHorizontal,
|
||||
verticalMargin = nextVertical
|
||||
)
|
||||
}
|
||||
|
||||
fun ReaderSettings.shouldShowPageWidthFormatControl(): Boolean {
|
||||
return readingMode == ReaderReadingMode.PAGINATED
|
||||
}
|
||||
|
||||
fun readerThemeById(themeId: String?): ReaderTheme? {
|
||||
return BuiltInReaderThemes.firstOrNull { it.id == themeId }
|
||||
}
|
||||
|
||||
fun readerTextureDisplayName(textureId: String?): String {
|
||||
return if (textureId == null) {
|
||||
"None"
|
||||
} else {
|
||||
ReaderTexture.entries.firstOrNull { it.id == textureId }?.displayName
|
||||
?: textureId
|
||||
.removePrefix(ReaderTextureFilePrefix)
|
||||
.substringAfterLast('/')
|
||||
.substringAfterLast('\\')
|
||||
.let { fileName -> fileName.substringBeforeLast('.', missingDelimiterValue = fileName) }
|
||||
.ifBlank { "Custom Image" }
|
||||
}
|
||||
}
|
||||
|
||||
fun RenderMode.toReaderReadingMode(): ReaderReadingMode {
|
||||
return when (this) {
|
||||
RenderMode.VERTICAL_SCROLL -> ReaderReadingMode.VERTICAL
|
||||
RenderMode.PAGINATED -> ReaderReadingMode.PAGINATED
|
||||
}
|
||||
}
|
||||
|
||||
fun ReaderTextAlign.toSharedReaderTextAlign(): SharedReaderTextAlign {
|
||||
return when (this) {
|
||||
ReaderTextAlign.DEFAULT,
|
||||
ReaderTextAlign.LEFT -> SharedReaderTextAlign.START
|
||||
ReaderTextAlign.RIGHT -> SharedReaderTextAlign.RIGHT
|
||||
ReaderTextAlign.JUSTIFY -> SharedReaderTextAlign.JUSTIFY
|
||||
}
|
||||
}
|
||||
|
||||
fun ReaderFont.toReaderSettingsFontFamily(): String {
|
||||
return when (this) {
|
||||
ReaderFont.ORIGINAL -> "Default"
|
||||
ReaderFont.MERRIWEATHER,
|
||||
ReaderFont.LORA -> "Serif"
|
||||
ReaderFont.LATO,
|
||||
ReaderFont.LEXEND -> "Sans"
|
||||
ReaderFont.ROBOTO_MONO -> "Mono"
|
||||
}
|
||||
}
|
||||
|
||||
private object ReaderAppearanceDefaults {
|
||||
const val fontSizePx = 18f
|
||||
const val minFontSizePx = 12
|
||||
const val maxFontSizePx = 42
|
||||
const val lineSpacing = 1.45f
|
||||
const val minLineSpacing = 1.0f
|
||||
const val maxLineSpacing = 2.8f
|
||||
const val marginPx = 48f
|
||||
const val minMarginPx = 0
|
||||
const val maxMarginPx = 160
|
||||
const val minParagraphSpacing = 0.5f
|
||||
const val maxParagraphSpacing = 2.5f
|
||||
const val minImageScale = 0.5f
|
||||
const val maxImageScale = 2.0f
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,114 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
data class ReaderMarkdownDocument(
|
||||
val blocks: List<ReaderMarkdownBlock>
|
||||
)
|
||||
|
||||
sealed interface ReaderMarkdownBlock {
|
||||
data class Heading(val level: Int, val text: String) : ReaderMarkdownBlock
|
||||
data class Paragraph(val text: String) : ReaderMarkdownBlock
|
||||
data class ListItems(val ordered: Boolean, val items: List<String>) : ReaderMarkdownBlock
|
||||
data class CodeBlock(val text: String) : ReaderMarkdownBlock
|
||||
data class Quote(val text: String) : ReaderMarkdownBlock
|
||||
}
|
||||
|
||||
object ReaderMarkdownParser {
|
||||
fun parse(markdown: String): ReaderMarkdownDocument {
|
||||
val lines = markdown.replace("\r\n", "\n").split('\n')
|
||||
val blocks = mutableListOf<ReaderMarkdownBlock>()
|
||||
val paragraph = mutableListOf<String>()
|
||||
var index = 0
|
||||
|
||||
fun flushParagraph() {
|
||||
if (paragraph.isNotEmpty()) {
|
||||
blocks += ReaderMarkdownBlock.Paragraph(paragraph.joinToString(" ").trim())
|
||||
paragraph.clear()
|
||||
}
|
||||
}
|
||||
|
||||
while (index < lines.size) {
|
||||
val line = lines[index]
|
||||
val trimmed = line.trim()
|
||||
when {
|
||||
trimmed.isBlank() -> {
|
||||
flushParagraph()
|
||||
index += 1
|
||||
}
|
||||
|
||||
trimmed.startsWith("```") -> {
|
||||
flushParagraph()
|
||||
val code = mutableListOf<String>()
|
||||
index += 1
|
||||
while (index < lines.size && !lines[index].trim().startsWith("```")) {
|
||||
code += lines[index]
|
||||
index += 1
|
||||
}
|
||||
if (index < lines.size) index += 1
|
||||
blocks += ReaderMarkdownBlock.CodeBlock(code.joinToString("\n").trimEnd())
|
||||
}
|
||||
|
||||
trimmed.headingLevel() != null -> {
|
||||
flushParagraph()
|
||||
val level = trimmed.headingLevel() ?: 1
|
||||
blocks += ReaderMarkdownBlock.Heading(
|
||||
level = level,
|
||||
text = trimmed.drop(level).trim()
|
||||
)
|
||||
index += 1
|
||||
}
|
||||
|
||||
trimmed.startsWith(">") -> {
|
||||
flushParagraph()
|
||||
val quote = mutableListOf<String>()
|
||||
while (index < lines.size && lines[index].trim().startsWith(">")) {
|
||||
quote += lines[index].trim().removePrefix(">").trim()
|
||||
index += 1
|
||||
}
|
||||
blocks += ReaderMarkdownBlock.Quote(quote.joinToString(" ").trim())
|
||||
}
|
||||
|
||||
trimmed.unorderedListText() != null || trimmed.orderedListText() != null -> {
|
||||
flushParagraph()
|
||||
val ordered = trimmed.orderedListText() != null
|
||||
val items = mutableListOf<String>()
|
||||
while (index < lines.size) {
|
||||
val itemLine = lines[index].trim()
|
||||
val item = if (ordered) itemLine.orderedListText() else itemLine.unorderedListText()
|
||||
if (item == null) break
|
||||
items += item
|
||||
index += 1
|
||||
}
|
||||
blocks += ReaderMarkdownBlock.ListItems(ordered = ordered, items = items)
|
||||
}
|
||||
|
||||
else -> {
|
||||
paragraph += trimmed
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flushParagraph()
|
||||
return ReaderMarkdownDocument(blocks)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.headingLevel(): Int? {
|
||||
val count = takeWhile { it == '#' }.length
|
||||
return count.takeIf { it in 1..6 && getOrNull(it) == ' ' }
|
||||
}
|
||||
|
||||
private fun String.unorderedListText(): String? {
|
||||
return if (length > 2 && first() in listOf('-', '*', '+') && this[1] == ' ') {
|
||||
drop(2).trim()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.orderedListText(): String? {
|
||||
val dotIndex = indexOf('.')
|
||||
if (dotIndex <= 0 || dotIndex + 1 >= length || this[dotIndex + 1] != ' ') return null
|
||||
return take(dotIndex).takeIf { number -> number.all { it.isDigit() } }
|
||||
?.let { drop(dotIndex + 2).trim() }
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
data class SearchResult(
|
||||
val locationInSource: Int,
|
||||
val locationTitle: String,
|
||||
val snippet: String,
|
||||
val query: String,
|
||||
val occurrenceIndexInLocation: Int,
|
||||
val chunkIndex: Int
|
||||
)
|
||||
|
||||
data class AiDefinitionResult(
|
||||
val definition: String? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
data class SummarizationResult(
|
||||
val summary: String? = null,
|
||||
val error: String? = null,
|
||||
val cost: Double? = null,
|
||||
val freeRemaining: Int? = null,
|
||||
val isCacheHit: Boolean = false
|
||||
)
|
||||
|
||||
data class RecapResult(
|
||||
val recap: String? = null,
|
||||
val error: String? = null,
|
||||
val cost: Double? = null,
|
||||
val freeRemaining: Int? = null
|
||||
)
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
private val DefaultReaderBottomToolIds: Set<String>
|
||||
get() = setOf(
|
||||
ReaderTool.SLIDER.id,
|
||||
ReaderTool.TOC.id,
|
||||
ReaderTool.FORMAT.id,
|
||||
ReaderTool.SEARCH.id
|
||||
)
|
||||
|
||||
enum class ReaderTool(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val category: String,
|
||||
val supportsDesktopQuickAction: Boolean = false
|
||||
) {
|
||||
DICTIONARY("dictionary", "External Apps", "Top Bar", supportsDesktopQuickAction = true),
|
||||
THEME("theme", "Theme Settings", "Top Bar", supportsDesktopQuickAction = true),
|
||||
SLIDER("slider", "Navigation Slider", "Bottom Bar"),
|
||||
TOC("toc", "Sidebar", "Bottom Bar"),
|
||||
FORMAT("format", "Text Formatting", "Bottom Bar"),
|
||||
SEARCH("search", "Search", "Bottom Bar", supportsDesktopQuickAction = true),
|
||||
AI_FEATURES("ai_features", "AI Features", "Bottom Bar"),
|
||||
TTS_CONTROLS("tts_controls", "TTS Controls", "Bottom Bar"),
|
||||
READING_MODE("reading_mode", "Reading Mode", "Overflow Menu"),
|
||||
BOOKMARK("bookmark", "Bookmark", "Overflow Menu", supportsDesktopQuickAction = true),
|
||||
TAP_TO_TURN("tap_to_turn", "Tap to Turn Pages", "Overflow Menu"),
|
||||
VOLUME_SCROLL("volume_scroll", "Volume Button Scrolling", "Overflow Menu"),
|
||||
PAGE_TURN_ANIM("page_turn_anim", "Realistic Page Turns", "Overflow Menu"),
|
||||
KEEP_SCREEN_ON("keep_screen_on", "Keep Screen On", "Overflow Menu"),
|
||||
VISUAL_OPTIONS("visual_options", "Visual Options", "Overflow Menu"),
|
||||
TTS_SETTINGS("tts_settings", "TTS Voice Settings", "Overflow Menu"),
|
||||
TTS_REPLACEMENTS("tts_replacements", "TTS Word Replacements", "Overflow Menu");
|
||||
|
||||
companion object {
|
||||
fun fromId(id: String): ReaderTool? {
|
||||
return entries.firstOrNull { it.id == id || it.name == id }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ReaderToolbarPreferences(
|
||||
val hiddenToolIds: Set<String> = emptySet(),
|
||||
val toolOrder: List<ReaderTool> = ReaderTool.entries.toList(),
|
||||
val bottomToolIds: Set<String> = DefaultReaderBottomToolIds
|
||||
) {
|
||||
fun sanitized(): ReaderToolbarPreferences {
|
||||
val orderedTools = (toolOrder + ReaderTool.entries.toList())
|
||||
.distinct()
|
||||
.filter { it in ReaderTool.entries }
|
||||
val knownToolIds = ReaderTool.entries.mapTo(mutableSetOf()) { it.id }
|
||||
return copy(
|
||||
hiddenToolIds = hiddenToolIds.filterTo(mutableSetOf()) { it in knownToolIds },
|
||||
toolOrder = orderedTools,
|
||||
bottomToolIds = bottomToolIds.filterTo(mutableSetOf()) { it in knownToolIds }
|
||||
)
|
||||
}
|
||||
|
||||
fun isVisible(tool: ReaderTool): Boolean {
|
||||
return tool.id !in hiddenToolIds
|
||||
}
|
||||
|
||||
fun isBottom(tool: ReaderTool): Boolean {
|
||||
return tool.id in bottomToolIds
|
||||
}
|
||||
|
||||
fun withVisibility(tool: ReaderTool, hidden: Boolean): ReaderToolbarPreferences {
|
||||
val nextHidden = if (hidden) hiddenToolIds + tool.id else hiddenToolIds - tool.id
|
||||
return copy(hiddenToolIds = nextHidden).sanitized()
|
||||
}
|
||||
|
||||
fun withBottomPlacement(tool: ReaderTool, bottom: Boolean): ReaderToolbarPreferences {
|
||||
val nextBottom = if (bottom) bottomToolIds + tool.id else bottomToolIds - tool.id
|
||||
return copy(bottomToolIds = nextBottom).sanitized()
|
||||
}
|
||||
|
||||
fun withToolOrder(order: List<ReaderTool>): ReaderToolbarPreferences {
|
||||
return copy(toolOrder = order).sanitized()
|
||||
}
|
||||
|
||||
fun orderedVisibleTools(): List<ReaderTool> {
|
||||
return sanitized().toolOrder.filter(::isVisible)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val defaultBottomToolIds: Set<String> get() = DefaultReaderBottomToolIds
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.security.MessageDigest
|
||||
|
||||
data class ReaderTtsChapterCacheInfo(
|
||||
val chapterTitle: String,
|
||||
val chunkCount: Int,
|
||||
val totalChunks: Int?,
|
||||
val sizeBytes: Long,
|
||||
val directoryPath: String,
|
||||
val matchingFilePaths: List<String> = emptyList()
|
||||
)
|
||||
|
||||
class ReaderTtsFileCacheManager(
|
||||
cacheRoot: File
|
||||
) {
|
||||
private val baseDir = cacheRoot
|
||||
|
||||
fun saveTotalChunks(bookTitle: String, chapterTitle: String?, totalChunks: Int) {
|
||||
val chapterDir = chapterDir(bookTitle, chapterTitle)
|
||||
if (!chapterDir.exists()) chapterDir.mkdirs()
|
||||
File(chapterDir, "total_chunks.txt").writeText(totalChunks.toString())
|
||||
}
|
||||
|
||||
fun getCacheFile(
|
||||
bookTitle: String,
|
||||
chapterTitle: String?,
|
||||
text: String,
|
||||
speakerId: String
|
||||
): File {
|
||||
val chapterDir = chapterDir(bookTitle, chapterTitle)
|
||||
if (!chapterDir.exists()) chapterDir.mkdirs()
|
||||
val hashParams = hash(text + speakerId + "CLOUD")
|
||||
val safeSpeaker = sanitizeFileToken(speakerId)
|
||||
return File(chapterDir, "cached_chunk_${safeSpeaker}_$hashParams.wav")
|
||||
}
|
||||
|
||||
fun getBookCacheDir(bookTitle: String): File {
|
||||
return File(baseDir, safeCacheSegment(bookTitle, "book"))
|
||||
}
|
||||
|
||||
fun getChapterCaches(bookTitle: String, speakerFilter: String? = null): List<ReaderTtsChapterCacheInfo> {
|
||||
val bookDir = getBookCacheDir(bookTitle)
|
||||
if (!bookDir.exists()) return emptyList()
|
||||
|
||||
return bookDir.listFiles()
|
||||
?.filter { it.isDirectory }
|
||||
?.mapNotNull { chapterDir ->
|
||||
val files = chapterDir.listFiles()
|
||||
?.filter { file -> file.isFile && file.name.endsWith(".wav") && file.matchesSpeaker(speakerFilter) }
|
||||
.orEmpty()
|
||||
if (files.isEmpty()) return@mapNotNull null
|
||||
|
||||
val metaFile = File(chapterDir, "total_chunks.txt")
|
||||
ReaderTtsChapterCacheInfo(
|
||||
chapterTitle = chapterDir.name,
|
||||
chunkCount = files.size,
|
||||
totalChunks = metaFile.takeIf { it.exists() }?.readText()?.toIntOrNull(),
|
||||
sizeBytes = files.sumOf { it.length() },
|
||||
directoryPath = chapterDir.absolutePath,
|
||||
matchingFilePaths = files.map { it.absolutePath }
|
||||
)
|
||||
}
|
||||
?.sortedBy { it.chapterTitle }
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
fun getCacheSummary(bookTitle: String, speakerId: String? = null): ReaderTtsCacheSummary {
|
||||
val allChapters = getChapterCaches(bookTitle, speakerFilter = null)
|
||||
val voiceChapters = speakerId
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { getChapterCaches(bookTitle, speakerFilter = it) }
|
||||
.orEmpty()
|
||||
return ReaderTtsCacheSummary(
|
||||
cachedChapterCount = allChapters.size,
|
||||
cachedChunkCount = allChapters.sumOf { it.chunkCount },
|
||||
currentVoiceChunkCount = voiceChapters.sumOf { it.chunkCount },
|
||||
totalSizeBytes = allChapters.sumOf { it.sizeBytes },
|
||||
currentVoiceSizeBytes = voiceChapters.sumOf { it.sizeBytes }
|
||||
)
|
||||
}
|
||||
|
||||
fun cachedSpeakers(bookTitle: String): List<String> {
|
||||
val bookDir = getBookCacheDir(bookTitle)
|
||||
if (!bookDir.exists()) return emptyList()
|
||||
return bookDir.listFiles()
|
||||
?.filter { it.isDirectory }
|
||||
?.flatMap { chapterDir ->
|
||||
chapterDir.listFiles()
|
||||
?.mapNotNull { it.speakerFromCacheFileName() }
|
||||
.orEmpty()
|
||||
}
|
||||
?.distinct()
|
||||
?.sorted()
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
fun deleteSpecificFiles(filePaths: List<String>, chapterDirectoryPath: String) {
|
||||
val chapterDir = File(chapterDirectoryPath)
|
||||
if (!chapterDir.isInsideBaseDir()) return
|
||||
filePaths.forEach { path ->
|
||||
File(path).takeIf { it.isInside(chapterDir) }?.delete()
|
||||
}
|
||||
if (chapterDir.listFiles()?.isEmpty() == true && chapterDir.isInsideBaseDir()) {
|
||||
chapterDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearBookCache(bookTitle: String) {
|
||||
getBookCacheDir(bookTitle).takeIf { it.isInsideBaseDir() }?.deleteRecursively()
|
||||
}
|
||||
|
||||
fun clearBookCacheForSpeaker(bookTitle: String, speakerId: String) {
|
||||
getChapterCaches(bookTitle, speakerFilter = speakerId).forEach { chapter ->
|
||||
deleteSpecificFiles(chapter.matchingFilePaths, chapter.directoryPath)
|
||||
}
|
||||
}
|
||||
|
||||
private fun chapterDir(bookTitle: String, chapterTitle: String?): File {
|
||||
return File(getBookCacheDir(bookTitle), safeCacheSegment(chapterTitle ?: "Unknown_Chapter", "chapter"))
|
||||
}
|
||||
|
||||
private fun File.matchesSpeaker(speakerFilter: String?): Boolean {
|
||||
if (speakerFilter.isNullOrBlank() || speakerFilter == "All") return true
|
||||
return speakerFromCacheFileName() == speakerFilter
|
||||
}
|
||||
|
||||
private fun File.speakerFromCacheFileName(): String? {
|
||||
if (!name.startsWith("cached_chunk_") || !name.endsWith(".wav")) return null
|
||||
val withoutPrefix = name.removePrefix("cached_chunk_")
|
||||
return withoutPrefix.substringBeforeLast('_').takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun safeCacheSegment(name: String, fallback: String): String {
|
||||
val normalized = name.trim().takeIf { it.isNotBlank() } ?: fallback
|
||||
val slug = normalized
|
||||
.replace(Regex("[^a-zA-Z0-9_-]+"), "_")
|
||||
.trim('_', '-')
|
||||
.ifBlank { fallback }
|
||||
.take(48)
|
||||
return "${slug}_${hash(normalized).take(16)}"
|
||||
}
|
||||
|
||||
private fun sanitizeFileToken(name: String): String {
|
||||
return name
|
||||
.replace(Regex("[^a-zA-Z0-9._-]+"), "_")
|
||||
.trim('.', '_', '-')
|
||||
.ifBlank { "default" }
|
||||
}
|
||||
|
||||
private fun File.isInsideBaseDir(): Boolean {
|
||||
return isInside(baseDir)
|
||||
}
|
||||
|
||||
private fun File.isInside(root: File): Boolean {
|
||||
val rootPath = runCatching { root.canonicalFile.path }.getOrNull() ?: return false
|
||||
val targetPath = runCatching { canonicalFile.path }.getOrNull() ?: return false
|
||||
return targetPath != rootPath && targetPath.startsWith(rootPath + File.separator)
|
||||
}
|
||||
|
||||
private fun hash(input: String): String {
|
||||
val bytes = MessageDigest.getInstance("SHA-256").digest(input.toByteArray())
|
||||
return bytes.joinToString("") { "%02x".format(it) }.take(16)
|
||||
}
|
||||
}
|
||||
|
||||
fun createReaderTtsWavHeaderUnknownLength(sampleRate: Int): ByteArray {
|
||||
val numChannels = 1
|
||||
val bitsPerSample = 16
|
||||
val byteRate = sampleRate * numChannels * bitsPerSample / 8
|
||||
val blockAlign = numChannels * bitsPerSample / 8
|
||||
|
||||
val header = ByteBuffer.allocate(44)
|
||||
header.order(ByteOrder.LITTLE_ENDIAN)
|
||||
header.put("RIFF".toByteArray(Charsets.US_ASCII))
|
||||
header.putInt(0x7FFFFFFF)
|
||||
header.put("WAVE".toByteArray(Charsets.US_ASCII))
|
||||
header.put("fmt ".toByteArray(Charsets.US_ASCII))
|
||||
header.putInt(16)
|
||||
header.putShort(1.toShort())
|
||||
header.putShort(numChannels.toShort())
|
||||
header.putInt(sampleRate)
|
||||
header.putInt(byteRate)
|
||||
header.putShort(blockAlign.toShort())
|
||||
header.putShort(bitsPerSample.toShort())
|
||||
header.put("data".toByteArray(Charsets.US_ASCII))
|
||||
header.putInt(0x7FFFFFFF - 36)
|
||||
|
||||
return header.array()
|
||||
}
|
||||
|
||||
fun patchReaderTtsWavHeader(file: File, pcmDataLength: Int) {
|
||||
RandomAccessFile(file, "rw").use { raf ->
|
||||
raf.seek(4)
|
||||
raf.writeInt(Integer.reverseBytes(36 + pcmDataLength))
|
||||
raf.seek(40)
|
||||
raf.writeInt(Integer.reverseBytes(pcmDataLength))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
data class ReaderTtsReplacementRule(
|
||||
val id: String,
|
||||
val from: String,
|
||||
val to: String,
|
||||
val enabled: Boolean = true,
|
||||
val isRegex: Boolean = false,
|
||||
val matchCase: Boolean = false,
|
||||
val wholeWord: Boolean = true,
|
||||
)
|
||||
|
||||
data class ReaderTtsReplacementBookSettings(
|
||||
val localRulesEnabled: Boolean = true,
|
||||
val globalRulesEnabled: Boolean = true,
|
||||
val disabledGlobalRuleIds: Set<String> = emptySet(),
|
||||
)
|
||||
|
||||
data class ReaderTtsReplacementPreferences(
|
||||
val isEnabled: Boolean = true,
|
||||
val globalRules: List<ReaderTtsReplacementRule> = emptyList(),
|
||||
val bookRules: Map<String, List<ReaderTtsReplacementRule>> = emptyMap(),
|
||||
val bookSettings: Map<String, ReaderTtsReplacementBookSettings> = emptyMap(),
|
||||
) {
|
||||
fun settingsForBook(bookId: String?): ReaderTtsReplacementBookSettings {
|
||||
return bookSettings[bookId.orEmpty()] ?: ReaderTtsReplacementBookSettings()
|
||||
}
|
||||
|
||||
fun rulesForBook(bookId: String?): List<ReaderTtsReplacementRule> {
|
||||
return bookRules[bookId.orEmpty()].orEmpty()
|
||||
}
|
||||
|
||||
fun activeRulesForBook(bookId: String?): List<ReaderTtsReplacementRule> {
|
||||
if (!isEnabled) return emptyList()
|
||||
val settings = settingsForBook(bookId)
|
||||
val inherited = if (settings.globalRulesEnabled) {
|
||||
globalRules.filter { it.id !in settings.disabledGlobalRuleIds }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
val local = if (settings.localRulesEnabled) rulesForBook(bookId) else emptyList()
|
||||
return inherited + local
|
||||
}
|
||||
|
||||
fun withBookSettings(
|
||||
bookId: String?,
|
||||
settings: ReaderTtsReplacementBookSettings,
|
||||
): ReaderTtsReplacementPreferences {
|
||||
return copy(bookSettings = bookSettings + (bookId.orEmpty() to settings))
|
||||
}
|
||||
|
||||
fun withBookRules(
|
||||
bookId: String?,
|
||||
rules: List<ReaderTtsReplacementRule>,
|
||||
): ReaderTtsReplacementPreferences {
|
||||
return copy(bookRules = bookRules + (bookId.orEmpty() to rules))
|
||||
}
|
||||
}
|
||||
|
||||
data class ReaderTtsReplacementValidation(
|
||||
val isValid: Boolean,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
data class ReaderTtsReplacementError(
|
||||
val ruleId: String,
|
||||
val message: String,
|
||||
)
|
||||
|
||||
data class ReaderTtsReplacementApplyResult(
|
||||
val text: String,
|
||||
val appliedRuleIds: List<String> = emptyList(),
|
||||
val errors: List<ReaderTtsReplacementError> = emptyList(),
|
||||
) {
|
||||
val hasUnmappableChanges: Boolean
|
||||
get() = appliedRuleIds.isNotEmpty()
|
||||
}
|
||||
|
||||
object ReaderTtsReplacementEngine {
|
||||
fun validate(rule: ReaderTtsReplacementRule): ReaderTtsReplacementValidation {
|
||||
val validation = ReaderWordReplacementEngine.validate(rule.toWordReplacementRule())
|
||||
return ReaderTtsReplacementValidation(
|
||||
isValid = validation.isValid,
|
||||
message = validation.message,
|
||||
)
|
||||
}
|
||||
|
||||
fun apply(
|
||||
text: String,
|
||||
preferences: ReaderTtsReplacementPreferences,
|
||||
bookId: String? = null,
|
||||
): ReaderTtsReplacementApplyResult {
|
||||
if (text.isEmpty() || !preferences.isEnabled) {
|
||||
return ReaderTtsReplacementApplyResult(text = text)
|
||||
}
|
||||
|
||||
val result = ReaderWordReplacementEngine.apply(
|
||||
text = text,
|
||||
rules = preferences.activeRulesForBook(bookId).map { it.toWordReplacementRule() },
|
||||
)
|
||||
|
||||
return ReaderTtsReplacementApplyResult(
|
||||
text = result.text,
|
||||
appliedRuleIds = result.appliedRuleIds,
|
||||
errors = result.errors.map {
|
||||
ReaderTtsReplacementError(ruleId = it.ruleId, message = it.message)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ReaderTtsReplacementRule.toWordReplacementRule(): ReaderWordReplacementRule {
|
||||
return ReaderWordReplacementRule(
|
||||
id = id,
|
||||
from = from,
|
||||
to = to,
|
||||
enabled = enabled,
|
||||
isRegex = isRegex,
|
||||
matchCase = matchCase,
|
||||
wholeWord = wholeWord,
|
||||
)
|
||||
}
|
||||
|
||||
fun ReaderTtsChunk.withTtsReplacements(
|
||||
preferences: ReaderTtsReplacementPreferences,
|
||||
bookId: String? = null,
|
||||
): ReaderTtsChunk {
|
||||
val result = ReaderTtsReplacementEngine.apply(
|
||||
text = text,
|
||||
preferences = preferences,
|
||||
bookId = bookId,
|
||||
)
|
||||
return copy(spokenText = result.text)
|
||||
}
|
||||
|
||||
fun List<ReaderTtsChunk>.withTtsReplacements(
|
||||
preferences: ReaderTtsReplacementPreferences,
|
||||
bookId: String? = null,
|
||||
): List<ReaderTtsChunk> = map { it.withTtsReplacements(preferences, bookId) }
|
||||
|
||||
object ReaderTtsReplacementSuggestions {
|
||||
val presets: List<ReaderTtsReplacementRule> = listOf(
|
||||
ReaderTtsReplacementRule(
|
||||
id = "suggestion_dr",
|
||||
from = "Dr.",
|
||||
to = "Doctor",
|
||||
wholeWord = false,
|
||||
),
|
||||
ReaderTtsReplacementRule(
|
||||
id = "suggestion_mr",
|
||||
from = "Mr.",
|
||||
to = "Mister",
|
||||
wholeWord = false,
|
||||
),
|
||||
ReaderTtsReplacementRule(
|
||||
id = "suggestion_mrs",
|
||||
from = "Mrs.",
|
||||
to = "Missus",
|
||||
wholeWord = false,
|
||||
),
|
||||
ReaderTtsReplacementRule(
|
||||
id = "suggestion_ms",
|
||||
from = "Ms.",
|
||||
to = "Miss",
|
||||
wholeWord = false,
|
||||
),
|
||||
ReaderTtsReplacementRule(
|
||||
id = "suggestion_vs",
|
||||
from = "vs.",
|
||||
to = "versus",
|
||||
wholeWord = false,
|
||||
),
|
||||
ReaderTtsReplacementRule(
|
||||
id = "suggestion_et_al",
|
||||
from = "et al.",
|
||||
to = "and others",
|
||||
wholeWord = false,
|
||||
),
|
||||
ReaderTtsReplacementRule(
|
||||
id = "suggestion_initials",
|
||||
from = """\b([A-Z])\.\s*([A-Z])\.""",
|
||||
to = "\$1 \$2",
|
||||
isRegex = true,
|
||||
wholeWord = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
object ReaderTtsReplacementPreferencesJson {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = false
|
||||
}
|
||||
|
||||
fun encode(preferences: ReaderTtsReplacementPreferences): String {
|
||||
return json.encodeToString(JsonElement.serializer(), toJsonElement(preferences))
|
||||
}
|
||||
|
||||
fun decodeOrEmpty(raw: String?): ReaderTtsReplacementPreferences {
|
||||
if (raw.isNullOrBlank()) return ReaderTtsReplacementPreferences()
|
||||
return runCatching {
|
||||
fromJsonElement(json.parseToJsonElement(raw))
|
||||
}.getOrNull() ?: ReaderTtsReplacementPreferences()
|
||||
}
|
||||
|
||||
fun toJsonElement(preferences: ReaderTtsReplacementPreferences): JsonElement {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"isEnabled" to JsonPrimitive(preferences.isEnabled),
|
||||
"globalRules" to rulesToJson(preferences.globalRules),
|
||||
"bookRules" to JsonObject(
|
||||
preferences.bookRules.mapValues { (_, rules) -> rulesToJson(rules) as JsonElement },
|
||||
),
|
||||
"bookSettings" to JsonObject(
|
||||
preferences.bookSettings.mapValues { (_, settings) -> settingsToJson(settings) as JsonElement },
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun fromJsonElement(element: JsonElement?): ReaderTtsReplacementPreferences {
|
||||
val root = element as? JsonObject ?: return ReaderTtsReplacementPreferences()
|
||||
val bookRules = root["bookRules"]?.jsonObjectOrNull()
|
||||
?.mapValues { (_, value) -> value.jsonArrayOrNull()?.mapNotNull(::ruleFromJson).orEmpty() }
|
||||
.orEmpty()
|
||||
val bookSettings = root["bookSettings"]?.jsonObjectOrNull()
|
||||
?.mapValues { (_, value) -> settingsFromJson(value) }
|
||||
.orEmpty()
|
||||
return ReaderTtsReplacementPreferences(
|
||||
isEnabled = root.booleanValue("isEnabled") ?: true,
|
||||
globalRules = root["globalRules"]?.jsonArrayOrNull()?.mapNotNull(::ruleFromJson).orEmpty(),
|
||||
bookRules = bookRules,
|
||||
bookSettings = bookSettings,
|
||||
)
|
||||
}
|
||||
|
||||
private fun rulesToJson(rules: List<ReaderTtsReplacementRule>): JsonArray {
|
||||
return JsonArray(rules.map(::ruleToJson))
|
||||
}
|
||||
|
||||
private fun ruleToJson(rule: ReaderTtsReplacementRule): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(rule.id),
|
||||
"from" to JsonPrimitive(rule.from),
|
||||
"to" to JsonPrimitive(rule.to),
|
||||
"enabled" to JsonPrimitive(rule.enabled),
|
||||
"isRegex" to JsonPrimitive(rule.isRegex),
|
||||
"matchCase" to JsonPrimitive(rule.matchCase),
|
||||
"wholeWord" to JsonPrimitive(rule.wholeWord),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun ruleFromJson(element: JsonElement): ReaderTtsReplacementRule? {
|
||||
val root = element as? JsonObject ?: return null
|
||||
val id = root.stringValue("id") ?: return null
|
||||
val from = root.stringValue("from") ?: return null
|
||||
return ReaderTtsReplacementRule(
|
||||
id = id,
|
||||
from = from,
|
||||
to = root.stringValue("to").orEmpty(),
|
||||
enabled = root.booleanValue("enabled") ?: true,
|
||||
isRegex = root.booleanValue("isRegex") ?: false,
|
||||
matchCase = root.booleanValue("matchCase") ?: false,
|
||||
wholeWord = root.booleanValue("wholeWord") ?: true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun settingsToJson(settings: ReaderTtsReplacementBookSettings): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"localRulesEnabled" to JsonPrimitive(settings.localRulesEnabled),
|
||||
"globalRulesEnabled" to JsonPrimitive(settings.globalRulesEnabled),
|
||||
"disabledGlobalRuleIds" to JsonArray(settings.disabledGlobalRuleIds.map(::JsonPrimitive)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun settingsFromJson(element: JsonElement): ReaderTtsReplacementBookSettings {
|
||||
val root = element as? JsonObject ?: return ReaderTtsReplacementBookSettings()
|
||||
return ReaderTtsReplacementBookSettings(
|
||||
localRulesEnabled = root.booleanValue("localRulesEnabled") ?: true,
|
||||
globalRulesEnabled = root.booleanValue("globalRulesEnabled") ?: true,
|
||||
disabledGlobalRuleIds = root["disabledGlobalRuleIds"]?.jsonArrayOrNull()
|
||||
?.mapNotNull { it.jsonPrimitiveOrNull()?.contentOrNull }
|
||||
?.toSet()
|
||||
.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonObject.stringValue(name: String): String? {
|
||||
return get(name)?.jsonPrimitiveOrNull()?.contentOrNull
|
||||
}
|
||||
|
||||
private fun JsonObject.booleanValue(name: String): Boolean? {
|
||||
return get(name)?.jsonPrimitiveOrNull()?.booleanOrNull
|
||||
}
|
||||
|
||||
private fun JsonElement.jsonObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
private fun JsonElement.jsonArrayOrNull(): JsonArray? = this as? JsonArray
|
||||
|
||||
private fun JsonElement.jsonPrimitiveOrNull() = when (this) {
|
||||
is JsonPrimitive -> this
|
||||
JsonNull -> null
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
data class ReaderWordReplacementRule(
|
||||
val id: String,
|
||||
val from: String,
|
||||
val to: String,
|
||||
val enabled: Boolean = true,
|
||||
val isRegex: Boolean = false,
|
||||
val matchCase: Boolean = false,
|
||||
val wholeWord: Boolean = true,
|
||||
)
|
||||
|
||||
data class ReaderWordReplacementValidation(
|
||||
val isValid: Boolean,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
data class ReaderWordReplacementError(
|
||||
val ruleId: String,
|
||||
val message: String,
|
||||
)
|
||||
|
||||
data class ReaderWordReplacementApplyResult(
|
||||
val text: String,
|
||||
val appliedRuleIds: List<String> = emptyList(),
|
||||
val errors: List<ReaderWordReplacementError> = emptyList(),
|
||||
)
|
||||
|
||||
object ReaderWordReplacementEngine {
|
||||
fun validate(rule: ReaderWordReplacementRule): ReaderWordReplacementValidation {
|
||||
if (rule.from.isBlank()) {
|
||||
return ReaderWordReplacementValidation(isValid = false, message = "Enter text to replace.")
|
||||
}
|
||||
if (!rule.isRegex) {
|
||||
return ReaderWordReplacementValidation(isValid = true)
|
||||
}
|
||||
return runCatching { rule.toRegex() }
|
||||
.fold(
|
||||
onSuccess = { ReaderWordReplacementValidation(isValid = true) },
|
||||
onFailure = {
|
||||
ReaderWordReplacementValidation(
|
||||
isValid = false,
|
||||
message = it.message ?: "This regex is not valid.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun apply(
|
||||
text: String,
|
||||
rules: List<ReaderWordReplacementRule>,
|
||||
): ReaderWordReplacementApplyResult {
|
||||
if (text.isEmpty() || rules.isEmpty()) {
|
||||
return ReaderWordReplacementApplyResult(text = text)
|
||||
}
|
||||
|
||||
var current = text
|
||||
val applied = mutableListOf<String>()
|
||||
val errors = mutableListOf<ReaderWordReplacementError>()
|
||||
|
||||
rules.forEach { rule ->
|
||||
if (!rule.enabled || rule.from.isBlank()) return@forEach
|
||||
val regex = runCatching { rule.toRegex() }
|
||||
.onFailure {
|
||||
errors += ReaderWordReplacementError(
|
||||
ruleId = rule.id,
|
||||
message = it.message ?: "Invalid regex.",
|
||||
)
|
||||
}
|
||||
.getOrNull() ?: return@forEach
|
||||
val replacement = if (rule.isRegex) rule.to else Regex.escapeReplacement(rule.to)
|
||||
val next = runCatching { regex.replace(current, replacement) }
|
||||
.onFailure {
|
||||
errors += ReaderWordReplacementError(
|
||||
ruleId = rule.id,
|
||||
message = it.message ?: "Invalid replacement.",
|
||||
)
|
||||
}
|
||||
.getOrNull() ?: return@forEach
|
||||
if (next != current) {
|
||||
applied += rule.id
|
||||
current = next
|
||||
}
|
||||
}
|
||||
|
||||
return ReaderWordReplacementApplyResult(
|
||||
text = current,
|
||||
appliedRuleIds = applied,
|
||||
errors = errors,
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderWordReplacementRule.toRegex(): Regex {
|
||||
val source = if (isRegex) from else Regex.escape(from)
|
||||
val boundedSource = if (wholeWord) {
|
||||
"""(?<![\p{L}\p{N}_])(?:$source)(?![\p{L}\p{N}_])"""
|
||||
} else {
|
||||
source
|
||||
}
|
||||
val options = if (matchCase) emptySet() else setOf(RegexOption.IGNORE_CASE)
|
||||
return Regex(pattern = boundedSource, options = options)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ReaderBookReplacementPreferences(
|
||||
val fileRules: Map<String, List<ReaderWordReplacementRule>> = emptyMap(),
|
||||
) {
|
||||
fun rulesForFile(fileId: String?): List<ReaderWordReplacementRule> {
|
||||
return fileRules[fileId.orEmpty()].orEmpty()
|
||||
}
|
||||
|
||||
fun activeRulesForFile(fileId: String?): List<ReaderWordReplacementRule> {
|
||||
return rulesForFile(fileId).filter { it.enabled && it.from.isNotBlank() }
|
||||
}
|
||||
|
||||
fun withFileRules(
|
||||
fileId: String?,
|
||||
rules: List<ReaderWordReplacementRule>,
|
||||
): ReaderBookReplacementPreferences {
|
||||
val key = fileId.orEmpty()
|
||||
val nextRules = if (rules.isEmpty()) {
|
||||
fileRules - key
|
||||
} else {
|
||||
fileRules + (key to rules)
|
||||
}
|
||||
return copy(fileRules = nextRules)
|
||||
}
|
||||
|
||||
fun scopedToFile(fileId: String?): ReaderBookReplacementPreferences {
|
||||
val key = fileId.orEmpty()
|
||||
val rules = rulesForFile(key)
|
||||
return if (rules.isEmpty()) {
|
||||
ReaderBookReplacementPreferences()
|
||||
} else {
|
||||
ReaderBookReplacementPreferences(fileRules = mapOf(key to rules))
|
||||
}
|
||||
}
|
||||
|
||||
fun signatureForFile(fileId: String?): String {
|
||||
return activeRulesForFile(fileId).joinToString(separator = "|") { rule ->
|
||||
listOf(
|
||||
rule.id,
|
||||
rule.from,
|
||||
rule.to,
|
||||
rule.enabled.toString(),
|
||||
rule.isRegex.toString(),
|
||||
rule.matchCase.toString(),
|
||||
rule.wholeWord.toString(),
|
||||
).joinToString(separator = "\u001F")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object ReaderBookReplacementEngine {
|
||||
fun validate(rule: ReaderWordReplacementRule): ReaderWordReplacementValidation {
|
||||
return ReaderWordReplacementEngine.validate(rule)
|
||||
}
|
||||
|
||||
fun apply(
|
||||
text: String,
|
||||
preferences: ReaderBookReplacementPreferences,
|
||||
fileId: String?,
|
||||
): ReaderWordReplacementApplyResult {
|
||||
return ReaderWordReplacementEngine.apply(
|
||||
text = text,
|
||||
rules = preferences.activeRulesForFile(fileId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object ReaderBookReplacementPreferencesJson {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = false
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
fun encode(preferences: ReaderBookReplacementPreferences): String {
|
||||
return json.encodeToString(preferences)
|
||||
}
|
||||
|
||||
fun decodeOrEmpty(raw: String?): ReaderBookReplacementPreferences {
|
||||
if (raw.isNullOrBlank()) return ReaderBookReplacementPreferences()
|
||||
return runCatching {
|
||||
json.decodeFromString<ReaderBookReplacementPreferences>(raw)
|
||||
}.getOrNull() ?: ReaderBookReplacementPreferences()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
data class ImportedBookFile(
|
||||
val name: String,
|
||||
val uriString: String?,
|
||||
val localPath: String?,
|
||||
val size: Long,
|
||||
val sourceFolder: String? = null,
|
||||
val id: String? = null
|
||||
)
|
||||
|
||||
interface BookRepository {
|
||||
fun observeBooks(): Flow<List<BookItem>>
|
||||
suspend fun getBook(bookId: String): BookItem?
|
||||
suspend fun upsertBook(book: BookItem)
|
||||
suspend fun removeBooks(bookIds: Set<String>)
|
||||
}
|
||||
|
||||
interface LibraryRepository {
|
||||
fun observeLibraryState(): Flow<LibraryState>
|
||||
suspend fun updateSortOrder(sortOrder: SortOrder)
|
||||
suspend fun updateFilters(filters: LibraryFilters)
|
||||
suspend fun updateSearchQuery(query: String)
|
||||
}
|
||||
|
||||
interface SettingsRepository {
|
||||
fun observeAppThemeMode(): Flow<AppThemeMode>
|
||||
fun observeReaderTheme(): Flow<ReaderTheme>
|
||||
suspend fun setAppThemeMode(mode: AppThemeMode)
|
||||
suspend fun setReaderTheme(theme: ReaderTheme)
|
||||
}
|
||||
|
||||
interface FileImporter {
|
||||
suspend fun importFiles(files: List<ImportedBookFile>): List<BookItem>
|
||||
}
|
||||
|
||||
interface ReaderDocumentLoader {
|
||||
suspend fun canLoad(type: FileType): Boolean
|
||||
suspend fun loadDocument(book: BookItem): SharedReaderDocument
|
||||
}
|
||||
|
||||
interface SyncAdapter {
|
||||
val isAvailable: Boolean
|
||||
suspend fun syncNow()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
interface TtsAdapter {
|
||||
val isAvailable: Boolean
|
||||
suspend fun speak(text: String)
|
||||
suspend fun pause() = Unit
|
||||
suspend fun resume() = Unit
|
||||
suspend fun stop()
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
fun sampleLibraryState(): LibraryState {
|
||||
val reference = Tag("reference", "Reference", 0xFF9575CD.toInt())
|
||||
val reading = Tag("reading", "Reading", 0xFF81C784.toInt())
|
||||
val now = currentTimestamp()
|
||||
|
||||
return LibraryState(
|
||||
books = listOf(
|
||||
BookItem(
|
||||
id = "sample_pdf",
|
||||
path = null,
|
||||
type = FileType.PDF,
|
||||
displayName = "Designing Data-Intensive Applications.pdf",
|
||||
timestamp = now - 1_000,
|
||||
title = "Designing Data-Intensive Applications",
|
||||
author = "Martin Kleppmann",
|
||||
progressPercentage = 42f,
|
||||
fileSize = 18_400_000,
|
||||
sourceFolder = "Samples",
|
||||
tags = listOf(reference, reading)
|
||||
),
|
||||
BookItem(
|
||||
id = "sample_epub",
|
||||
path = null,
|
||||
type = FileType.EPUB,
|
||||
displayName = "The Pragmatic Programmer.epub",
|
||||
timestamp = now - 2_000,
|
||||
title = "The Pragmatic Programmer",
|
||||
author = "Andrew Hunt, David Thomas",
|
||||
progressPercentage = 12f,
|
||||
fileSize = 4_200_000,
|
||||
sourceFolder = "Samples",
|
||||
tags = listOf(reading)
|
||||
),
|
||||
BookItem(
|
||||
id = "sample_doc",
|
||||
path = null,
|
||||
type = FileType.DOCX,
|
||||
displayName = "Research Notes.docx",
|
||||
timestamp = now - 3_000,
|
||||
title = "Research Notes",
|
||||
author = "Local",
|
||||
progressPercentage = 0f,
|
||||
fileSize = 920_000,
|
||||
sourceFolder = "Samples"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun sampleReaderScreenState(): SharedReaderScreenState {
|
||||
val library = sampleLibraryState()
|
||||
val tags = library.books.flatMap { it.tags }.distinctBy { it.id }
|
||||
return SharedReaderScreenState(
|
||||
rawLibraryBooks = library.books,
|
||||
recentBooks = library.books.filter { it.isRecent },
|
||||
libraryBooks = library.books,
|
||||
selectedBookIds = library.selectedBookIds,
|
||||
searchQuery = library.searchQuery,
|
||||
sortOrder = library.sortOrder,
|
||||
libraryFilters = library.filters,
|
||||
recentFilesLimit = library.recentLimit,
|
||||
allTags = tags
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
data class SharedHomeScreenModel(
|
||||
val recentBooks: List<BookItem>,
|
||||
val openTabs: List<BookItem>,
|
||||
val selectedBooks: List<BookItem>,
|
||||
val isContextualModeActive: Boolean,
|
||||
val deviceLimitState: DeviceLimitReachedState,
|
||||
val isEmpty: Boolean,
|
||||
val isLibraryEmpty: Boolean
|
||||
)
|
||||
|
||||
fun SharedReaderScreenState.toHomeScreenModel(): SharedHomeScreenModel {
|
||||
val homeRecentBooks = recentBooks.filter { it.isRecent }
|
||||
return SharedHomeScreenModel(
|
||||
recentBooks = homeRecentBooks,
|
||||
openTabs = openTabs,
|
||||
selectedBooks = rawLibraryBooks.filter { it.id in selectedBookIds },
|
||||
isContextualModeActive = selectedBookIds.isNotEmpty(),
|
||||
deviceLimitState = deviceLimitState,
|
||||
isEmpty = homeRecentBooks.isEmpty() && (!isTabsEnabled || openTabs.isEmpty()),
|
||||
isLibraryEmpty = rawLibraryBooks.isEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
data class SharedLibraryScreenModel(
|
||||
val selectedBooks: List<BookItem>,
|
||||
val isContextualModeActive: Boolean,
|
||||
val selectedShelves: Set<String>,
|
||||
val isShelfContextualModeActive: Boolean,
|
||||
val sortOrder: SortOrder,
|
||||
val shelves: List<Shelf>,
|
||||
val rawLibraryBooks: List<BookItem>,
|
||||
val containsFolderItemsInSelection: Boolean,
|
||||
val isSearchActive: Boolean,
|
||||
val searchQuery: String
|
||||
)
|
||||
|
||||
fun SharedReaderScreenState.toLibraryScreenModel(): SharedLibraryScreenModel {
|
||||
val selected = rawLibraryBooks.filter { it.id in selectedBookIds }
|
||||
return SharedLibraryScreenModel(
|
||||
selectedBooks = selected,
|
||||
isContextualModeActive = selectedBookIds.isNotEmpty(),
|
||||
selectedShelves = selectedShelfIds,
|
||||
isShelfContextualModeActive = selectedShelfIds.isNotEmpty(),
|
||||
sortOrder = sortOrder,
|
||||
shelves = shelves,
|
||||
rawLibraryBooks = rawLibraryBooks,
|
||||
containsFolderItemsInSelection = selected.any { it.sourceFolder != null },
|
||||
isSearchActive = isSearchActive,
|
||||
searchQuery = searchQuery
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,29 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
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
|
||||
) {
|
||||
companion object {
|
||||
val Standard = SharedFeaturePolicy()
|
||||
val OssOnline = SharedFeaturePolicy(
|
||||
networkAccess = true,
|
||||
aiAndCloud = true,
|
||||
byokAi = true
|
||||
)
|
||||
val OssOffline = SharedFeaturePolicy(
|
||||
networkAccess = false,
|
||||
opdsCatalogs = false,
|
||||
aiAndCloud = false,
|
||||
byokAi = true,
|
||||
externalLookup = false,
|
||||
projectLinks = false,
|
||||
googleFontsDownload = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import kotlin.math.log10
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
fun formatFileSize(bytes: Long): String {
|
||||
if (bytes <= 0) return "Unknown"
|
||||
val units = arrayOf("B", "KB", "MB", "GB", "TB")
|
||||
val digitGroups = (log10(bytes.toDouble()) / log10(1024.0)).toInt().coerceIn(units.indices)
|
||||
return formatDecimal(bytes / 1024.0.pow(digitGroups.toDouble()), 2) + " " + units[digitGroups]
|
||||
}
|
||||
|
||||
fun progressPercentValue(progressPercentage: Float?): Int {
|
||||
return (progressPercentage ?: 0f).coerceIn(0f, 100f).roundToInt()
|
||||
}
|
||||
|
||||
fun progressFraction(progressPercentage: Float?): Float {
|
||||
return progressPercentValue(progressPercentage) / 100f
|
||||
}
|
||||
|
||||
fun BookItem.cardTitle(): String {
|
||||
return title?.takeIf { it.isNotBlank() } ?: displayName
|
||||
}
|
||||
|
||||
fun BookItem.cardAuthor(fallback: String = "No author listed"): String {
|
||||
return author
|
||||
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
||||
?: fallback
|
||||
}
|
||||
|
||||
fun BookItem.isOpdsStream(): Boolean {
|
||||
return path?.startsWith("opds-pse://") == true
|
||||
}
|
||||
|
||||
fun BookItem.matchesSourceFolders(sourceFolders: Set<String>): Boolean {
|
||||
if (sourceFolders.isEmpty()) return true
|
||||
val matchesInAppStorage = IN_APP_STORAGE_SOURCE in sourceFolders &&
|
||||
sourceFolder == null &&
|
||||
!isOpdsStream()
|
||||
return matchesInAppStorage || sourceFolder in sourceFolders
|
||||
}
|
||||
|
||||
private fun formatDecimal(value: Double, decimals: Int): String {
|
||||
val factor = 10.0.pow(decimals)
|
||||
val rounded = (value * factor).roundToInt() / factor
|
||||
val text = rounded.toString()
|
||||
val dotIndex = text.indexOf('.')
|
||||
if (dotIndex < 0) return text + "." + "0".repeat(decimals)
|
||||
val currentDecimals = text.length - dotIndex - 1
|
||||
return if (currentDecimals >= decimals) {
|
||||
text.take(dotIndex + 1 + decimals)
|
||||
} else {
|
||||
text + "0".repeat(decimals - currentDecimals)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
const val EPISTEME_POLICY_BASE_URL = "https://aryan-raj3112.github.io/reader-policy"
|
||||
|
||||
enum class SharedLegalProfile {
|
||||
STANDARD,
|
||||
OSS
|
||||
}
|
||||
|
||||
data class SharedLegalLinks(
|
||||
val privacyPolicyUrl: String,
|
||||
val termsUrl: String,
|
||||
val licensesUrl: String
|
||||
)
|
||||
|
||||
fun sharedLegalLinksForProfile(profile: SharedLegalProfile): SharedLegalLinks {
|
||||
val privacyPath: String
|
||||
val termsPath: String
|
||||
when (profile) {
|
||||
SharedLegalProfile.STANDARD -> {
|
||||
privacyPath = "privacy-policy.html"
|
||||
termsPath = "terms-and-conditions.html"
|
||||
}
|
||||
SharedLegalProfile.OSS -> {
|
||||
privacyPath = "oss-privacy-policy.html"
|
||||
termsPath = "oss-terms-of-service.html"
|
||||
}
|
||||
}
|
||||
return SharedLegalLinks(
|
||||
privacyPolicyUrl = "$EPISTEME_POLICY_BASE_URL/$privacyPath",
|
||||
termsUrl = "$EPISTEME_POLICY_BASE_URL/$termsPath",
|
||||
licensesUrl = "$EPISTEME_POLICY_BASE_URL/licenses.html"
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,823 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.floatOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import org.dueattendant149.bookreader.shared.pdf.SharedPdfHighlighterPalette
|
||||
import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderViewport
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderBookmark
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderPageSpreadMode
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderSettings
|
||||
import org.dueattendant149.bookreader.shared.reader.SharedReaderTextAlign
|
||||
|
||||
data class SharedLibrarySnapshot(
|
||||
val books: List<BookItem> = emptyList(),
|
||||
val shelfRecords: List<ShelfRecord> = emptyList(),
|
||||
val shelfRefs: List<BookShelfRef> = emptyList(),
|
||||
val tags: List<Tag> = emptyList(),
|
||||
val customFonts: List<CustomFontItem> = emptyList(),
|
||||
val syncedFolders: List<SyncedFolder> = emptyList(),
|
||||
val recentFilesLimit: Int = 12,
|
||||
val isTabsEnabled: Boolean = true,
|
||||
val openTabIds: List<String> = emptyList(),
|
||||
val activeTabBookId: String? = null,
|
||||
val pinnedHomeBookIds: Set<String> = emptySet(),
|
||||
val pinnedLibraryBookIds: Set<String> = emptySet(),
|
||||
val useStrictFileFilter: 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 customReaderThemes: List<ReaderTheme> = emptyList(),
|
||||
val readerDefaultSettings: ReaderSettings = ReaderSettings(),
|
||||
val pdfReaderDefaultSettings: ReaderSettings = ReaderSettings(themeId = "no_theme"),
|
||||
val desktopReaderDefaultsVersion: Int = 0,
|
||||
val readerToolbarPreferences: ReaderToolbarPreferences = ReaderToolbarPreferences(),
|
||||
val readerHighlightPalette: ReaderHighlightPalette = ReaderHighlightPalette(),
|
||||
val pdfHighlighterPalette: SharedPdfHighlighterPalette = SharedPdfHighlighterPalette(),
|
||||
val readerTtsReplacementPreferences: ReaderTtsReplacementPreferences = ReaderTtsReplacementPreferences()
|
||||
)
|
||||
|
||||
object SharedLibrarySnapshotJson {
|
||||
private const val SCHEMA_VERSION = 22
|
||||
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
fun decodeOrEmpty(rawJson: String): SharedLibrarySnapshot {
|
||||
val root = runCatching {
|
||||
json.parseToJsonElement(rawJson).jsonObject
|
||||
}.getOrNull() ?: return SharedLibrarySnapshot()
|
||||
|
||||
val schemaVersion = root.int("schemaVersion", 1)
|
||||
val openTabIds = root.stringArray("openTabIds")
|
||||
val readerDefaultSettings = root["readerDefaultSettings"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderSettingsOrNull()
|
||||
?: ReaderSettings()
|
||||
return SharedLibrarySnapshot(
|
||||
books = root.array("books")
|
||||
.mapNotNull { it.asBookItemOrNull() }
|
||||
.migrateLegacyRecentState(schemaVersion, openTabIds),
|
||||
shelfRecords = root.array("shelves").mapNotNull { it.asShelfRecordOrNull() },
|
||||
shelfRefs = root.array("bookShelfRefs").mapNotNull { it.asBookShelfRefOrNull() },
|
||||
tags = root.array("tags").mapNotNull { it.asTagOrNull() },
|
||||
customFonts = root.array("customFonts").mapNotNull { it.asCustomFontItemOrNull() },
|
||||
syncedFolders = root.array("syncedFolders").mapNotNull { it.asSyncedFolderOrNull() },
|
||||
recentFilesLimit = root.int("recentFilesLimit", 12),
|
||||
isTabsEnabled = root.boolean("isTabsEnabled", true),
|
||||
openTabIds = openTabIds,
|
||||
activeTabBookId = root.string("activeTabBookId"),
|
||||
pinnedHomeBookIds = root.stringArray("pinnedHomeBookIds").toSet(),
|
||||
pinnedLibraryBookIds = root.stringArray("pinnedLibraryBookIds").toSet(),
|
||||
useStrictFileFilter = root.boolean("useStrictFileFilter", false),
|
||||
appThemeMode = root.string("appThemeMode")
|
||||
?.let { runCatching { AppThemeMode.valueOf(it) }.getOrNull() }
|
||||
?: AppThemeMode.SYSTEM,
|
||||
appContrastOption = root.string("appContrastOption")
|
||||
?.let { runCatching { AppContrastOption.valueOf(it) }.getOrNull() }
|
||||
?: AppContrastOption.STANDARD,
|
||||
appTextDimFactorLight = root.float("appTextDimFactorLight")
|
||||
?: root.float("appTextDimFactor")
|
||||
?: 1.0f,
|
||||
appTextDimFactorDark = root.float("appTextDimFactorDark")
|
||||
?: 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() },
|
||||
customReaderThemes = root.array("customReaderThemes")
|
||||
.mapNotNull { it.asReaderThemeOrNull() }
|
||||
.sanitizeCustomReaderThemes(),
|
||||
readerDefaultSettings = readerDefaultSettings.migrateLegacyDefaultReadingMode(schemaVersion),
|
||||
pdfReaderDefaultSettings = root["pdfReaderDefaultSettings"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderSettingsOrNull()
|
||||
?: ReaderSettings(themeId = "no_theme"),
|
||||
desktopReaderDefaultsVersion = root.int("desktopReaderDefaultsVersion", 0),
|
||||
readerToolbarPreferences = root["readerToolbarPreferences"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderToolbarPreferencesOrNull()
|
||||
?: ReaderToolbarPreferences(),
|
||||
readerHighlightPalette = root["readerHighlightPalette"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderHighlightPaletteOrNull()
|
||||
?: ReaderHighlightPalette(),
|
||||
pdfHighlighterPalette = root["pdfHighlighterPalette"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asSharedPdfHighlighterPaletteOrNull()
|
||||
?: SharedPdfHighlighterPalette(),
|
||||
readerTtsReplacementPreferences = root["readerTtsReplacementPreferences"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.let { ReaderTtsReplacementPreferencesJson.fromJsonElement(it) }
|
||||
?: ReaderTtsReplacementPreferences()
|
||||
)
|
||||
}
|
||||
|
||||
fun encode(snapshot: SharedLibrarySnapshot): String {
|
||||
val root = JsonObject(
|
||||
mapOf(
|
||||
"schemaVersion" to JsonPrimitive(SCHEMA_VERSION),
|
||||
"books" to JsonArray(snapshot.books.map { it.toJsonObject() }),
|
||||
"shelves" to JsonArray(snapshot.shelfRecords.map { it.toJsonObject() }),
|
||||
"bookShelfRefs" to JsonArray(snapshot.shelfRefs.map { it.toJsonObject() }),
|
||||
"tags" to JsonArray(snapshot.tags.map { it.toJsonObject() }),
|
||||
"customFonts" to JsonArray(snapshot.customFonts.map { it.toJsonObject() }),
|
||||
"syncedFolders" to JsonArray(snapshot.syncedFolders.map { it.toJsonObject() }),
|
||||
"recentFilesLimit" to JsonPrimitive(snapshot.recentFilesLimit),
|
||||
"isTabsEnabled" to JsonPrimitive(snapshot.isTabsEnabled),
|
||||
"openTabIds" to snapshot.openTabIds.asJsonArray(),
|
||||
"activeTabBookId" to snapshot.activeTabBookId.asJson(),
|
||||
"pinnedHomeBookIds" to snapshot.pinnedHomeBookIds.toList().asJsonArray(),
|
||||
"pinnedLibraryBookIds" to snapshot.pinnedLibraryBookIds.toList().asJsonArray(),
|
||||
"useStrictFileFilter" to JsonPrimitive(snapshot.useStrictFileFilter),
|
||||
"appThemeMode" to JsonPrimitive(snapshot.appThemeMode.name),
|
||||
"appContrastOption" to JsonPrimitive(snapshot.appContrastOption.name),
|
||||
"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() }),
|
||||
"customReaderThemes" to JsonArray(
|
||||
snapshot.customReaderThemes.sanitizeCustomReaderThemes().map { it.toJsonObject() }
|
||||
),
|
||||
"readerDefaultSettings" to snapshot.readerDefaultSettings.asJson(),
|
||||
"pdfReaderDefaultSettings" to snapshot.pdfReaderDefaultSettings.asJson(),
|
||||
"desktopReaderDefaultsVersion" to JsonPrimitive(snapshot.desktopReaderDefaultsVersion),
|
||||
"readerToolbarPreferences" to snapshot.readerToolbarPreferences.sanitized().toJsonObject(),
|
||||
"readerHighlightPalette" to snapshot.readerHighlightPalette.sanitized().toJsonObject(),
|
||||
"pdfHighlighterPalette" to snapshot.pdfHighlighterPalette.sanitized().toJsonObject(),
|
||||
"readerTtsReplacementPreferences" to ReaderTtsReplacementPreferencesJson.toJsonElement(
|
||||
snapshot.readerTtsReplacementPreferences,
|
||||
)
|
||||
)
|
||||
)
|
||||
return json.encodeToString(JsonElement.serializer(), root)
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.array(name: String): List<JsonElement> {
|
||||
return runCatching { this[name]?.jsonArray?.toList().orEmpty() }.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun JsonObject.stringArray(name: String): List<String> {
|
||||
return array(name).mapNotNull { element ->
|
||||
runCatching { element.jsonPrimitive.content }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.intArray(name: String): List<Int> {
|
||||
return array(name).mapNotNull { element ->
|
||||
runCatching { element.jsonPrimitive.intOrNull }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.string(name: String): String? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.long(name: String, fallback: Long = 0L): Long {
|
||||
return runCatching { this[name]?.jsonPrimitive?.longOrNull }.getOrNull() ?: fallback
|
||||
}
|
||||
|
||||
private fun JsonObject.nullableLong(name: String): Long? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.longOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.int(name: String): Int? {
|
||||
return runCatching { this[name]?.jsonPrimitive?.content?.toIntOrNull() }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.int(name: String, fallback: Int): Int {
|
||||
return int(name) ?: fallback
|
||||
}
|
||||
|
||||
private fun JsonObject.float(name: String): Float? {
|
||||
return runCatching { this[name]?.jsonPrimitive?.floatOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.double(name: String): Double? {
|
||||
return runCatching { this[name]?.jsonPrimitive?.doubleOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.boolean(name: String, fallback: Boolean): Boolean {
|
||||
return runCatching { this[name]?.jsonPrimitive?.booleanOrNull }.getOrNull() ?: fallback
|
||||
}
|
||||
|
||||
private fun List<BookItem>.migrateLegacyRecentState(schemaVersion: Int, openTabIds: List<String>): List<BookItem> {
|
||||
if (schemaVersion >= 3) return this
|
||||
val openedBookIds = openTabIds.toSet()
|
||||
return map { book ->
|
||||
if (book.isRecent && !book.hasReaderFootprint(openedBookIds)) {
|
||||
book.copy(isRecent = false)
|
||||
} else {
|
||||
book
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun BookItem.hasReaderFootprint(openedBookIds: Set<String>): Boolean {
|
||||
return id in openedBookIds ||
|
||||
lastPageIndex != null ||
|
||||
readerPosition != null ||
|
||||
(progressPercentage ?: 0f) > 0f ||
|
||||
readerSettings != null ||
|
||||
readerBookmarks.isNotEmpty() ||
|
||||
readerHighlights.isNotEmpty() ||
|
||||
pdfReaderViewport != null
|
||||
}
|
||||
|
||||
private fun ReaderSettings.migrateLegacyDefaultReadingMode(schemaVersion: Int): ReaderSettings {
|
||||
if (schemaVersion >= 17 || readingMode != ReaderReadingMode.PAGINATED) return this
|
||||
val oldDefaultSettings = ReaderSettings(readingMode = ReaderReadingMode.PAGINATED)
|
||||
return if (this == oldDefaultSettings) copy(readingMode = ReaderReadingMode.VERTICAL) else this
|
||||
}
|
||||
|
||||
private fun JsonElement.asBookItemOrNull(): BookItem? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val id = obj.string("id") ?: return null
|
||||
val displayName = obj.string("displayName") ?: return null
|
||||
val type = obj.string("type")?.let { runCatching { FileType.valueOf(it) }.getOrNull() } ?: FileType.UNKNOWN
|
||||
return BookItem(
|
||||
id = id,
|
||||
path = obj.string("path"),
|
||||
type = type,
|
||||
displayName = displayName,
|
||||
timestamp = obj.long("timestamp"),
|
||||
coverImagePath = obj.string("coverImagePath"),
|
||||
title = obj.string("title"),
|
||||
author = obj.string("author"),
|
||||
description = obj.string("description"),
|
||||
originalTitle = obj.string("originalTitle"),
|
||||
originalAuthor = obj.string("originalAuthor"),
|
||||
originalSeriesName = obj.string("originalSeriesName"),
|
||||
originalSeriesIndex = obj.double("originalSeriesIndex"),
|
||||
originalDescription = obj.string("originalDescription"),
|
||||
progressPercentage = obj.float("progressPercentage"),
|
||||
isRecent = obj.boolean("isRecent", true),
|
||||
fileSize = obj.long("fileSize"),
|
||||
fileContentModifiedTimestamp = obj.long("fileContentModifiedTimestamp"),
|
||||
sourceFolder = obj.string("sourceFolder"),
|
||||
folderTextMetadataParsed = obj.boolean("folderTextMetadataParsed", false),
|
||||
seriesName = obj.string("seriesName"),
|
||||
seriesIndex = obj.double("seriesIndex"),
|
||||
tags = obj.array("tags").mapNotNull { it.asTagOrNull() },
|
||||
lastPageIndex = obj.int("lastPageIndex"),
|
||||
readerPosition = obj["readerPosition"]?.takeUnless { it is JsonNull }?.asReaderLocatorOrNull(),
|
||||
readerSettings = obj["readerSettings"]?.takeUnless { it is JsonNull }?.asReaderSettingsOrNull(),
|
||||
readerBookmarks = obj.array("readerBookmarks").mapNotNull { it.asReaderBookmarkOrNull() },
|
||||
readerHighlights = obj.array("readerHighlights").mapNotNull { it.asReaderHighlightOrNull() },
|
||||
pdfReaderViewport = obj["pdfReaderViewport"]?.takeUnless { it is JsonNull }?.asSharedPdfReaderViewportOrNull(),
|
||||
readingPositionModifiedTimestamp = obj.long("readingPositionModifiedTimestamp")
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asShelfRecordOrNull(): ShelfRecord? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return ShelfRecord(
|
||||
id = obj.string("id") ?: return null,
|
||||
name = obj.string("name") ?: return null,
|
||||
isSmart = obj.boolean("isSmart", false),
|
||||
smartRulesJson = obj.string("smartRulesJson")
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asBookShelfRefOrNull(): BookShelfRef? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return BookShelfRef(
|
||||
bookId = obj.string("bookId") ?: return null,
|
||||
shelfId = obj.string("shelfId") ?: return null,
|
||||
addedAt = obj.long("addedAt")
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asTagOrNull(): Tag? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return Tag(
|
||||
id = obj.string("id") ?: return null,
|
||||
name = obj.string("name") ?: return null,
|
||||
color = runCatching {
|
||||
obj["color"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content?.toIntOrNull()
|
||||
}.getOrNull()
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asCustomFontItemOrNull(): CustomFontItem? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return CustomFontItem(
|
||||
id = obj.string("id") ?: return null,
|
||||
displayName = obj.string("displayName") ?: return null,
|
||||
fileName = obj.string("fileName") ?: return null,
|
||||
fileExtension = obj.string("fileExtension") ?: return null,
|
||||
path = obj.string("path") ?: return null,
|
||||
timestamp = obj.long("timestamp"),
|
||||
isDeleted = obj.boolean("isDeleted", false)
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asSyncedFolderOrNull(): SyncedFolder? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return SyncedFolder(
|
||||
uriString = obj.string("uriString") ?: return null,
|
||||
name = obj.string("name") ?: return null,
|
||||
lastScanTime = obj.long("lastScanTime"),
|
||||
allowedFileTypes = obj.stringArray("allowedFileTypes")
|
||||
.mapNotNull { runCatching { FileType.valueOf(it) }.getOrNull() }
|
||||
.filter { it in SharedFileCapabilities.knownFileTypes }
|
||||
.toSet()
|
||||
.ifEmpty { SharedFileCapabilities.knownFileTypes },
|
||||
localSyncEnabled = obj.boolean("localSyncEnabled", true)
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asCustomAppThemeOrNull(): CustomAppTheme? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return CustomAppTheme(
|
||||
id = obj.string("id") ?: return null,
|
||||
name = obj.string("name") ?: return null,
|
||||
seedColor = obj.int("seedColor")?.let { Color(it) } ?: return null
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asReaderThemeOrNull(): ReaderTheme? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return ReaderTheme(
|
||||
id = obj.string("id") ?: return null,
|
||||
name = obj.string("name") ?: return null,
|
||||
backgroundColor = obj.int("bgColor")?.let { Color(it) } ?: return null,
|
||||
textColor = obj.int("textColor")?.let { Color(it) } ?: return null,
|
||||
isDark = obj.boolean("isDark", false),
|
||||
textureId = obj.string("textureId")?.takeIf { it.isNotBlank() },
|
||||
isCustom = true
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
"id" to JsonPrimitive(id),
|
||||
"path" to path.asJson(),
|
||||
"type" to JsonPrimitive(type.name),
|
||||
"displayName" to JsonPrimitive(displayName),
|
||||
"timestamp" to JsonPrimitive(timestamp),
|
||||
"coverImagePath" to coverImagePath.asJson(),
|
||||
"title" to title.asJson(),
|
||||
"author" to author.asJson(),
|
||||
"description" to description.asJson(),
|
||||
"originalTitle" to originalTitle.asJson(),
|
||||
"originalAuthor" to originalAuthor.asJson(),
|
||||
"originalSeriesName" to originalSeriesName.asJson(),
|
||||
"originalSeriesIndex" to originalSeriesIndex.asJson(),
|
||||
"originalDescription" to originalDescription.asJson(),
|
||||
"progressPercentage" to progressPercentage.asJson(),
|
||||
"isRecent" to JsonPrimitive(isRecent),
|
||||
"fileSize" to JsonPrimitive(fileSize),
|
||||
"fileContentModifiedTimestamp" to JsonPrimitive(fileContentModifiedTimestamp),
|
||||
"sourceFolder" to sourceFolder.asJson(),
|
||||
"folderTextMetadataParsed" to JsonPrimitive(folderTextMetadataParsed),
|
||||
"seriesName" to seriesName.asJson(),
|
||||
"seriesIndex" to seriesIndex.asJson(),
|
||||
"tags" to JsonArray(tags.map { it.toJsonObject() }),
|
||||
"lastPageIndex" to lastPageIndex.asJson(),
|
||||
"readerPosition" to readerPosition.asJson(),
|
||||
"readerSettings" to readerSettings.asJson(),
|
||||
"readerBookmarks" to JsonArray(readerBookmarks.map { it.toJsonObject() }),
|
||||
"readerHighlights" to JsonArray(readerHighlights.map { it.toJsonObject() }),
|
||||
"pdfReaderViewport" to pdfReaderViewport.asJson(),
|
||||
"readingPositionModifiedTimestamp" to JsonPrimitive(readingPositionModifiedTimestamp)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ShelfRecord.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"name" to JsonPrimitive(name),
|
||||
"isSmart" to JsonPrimitive(isSmart),
|
||||
"smartRulesJson" to smartRulesJson.asJson()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun BookShelfRef.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"bookId" to JsonPrimitive(bookId),
|
||||
"shelfId" to JsonPrimitive(shelfId),
|
||||
"addedAt" to JsonPrimitive(addedAt)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun Tag.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"name" to JsonPrimitive(name),
|
||||
"color" to color.asJson()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun CustomFontItem.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"displayName" to JsonPrimitive(displayName),
|
||||
"fileName" to JsonPrimitive(fileName),
|
||||
"fileExtension" to JsonPrimitive(fileExtension),
|
||||
"path" to JsonPrimitive(path),
|
||||
"timestamp" to JsonPrimitive(timestamp),
|
||||
"isDeleted" to JsonPrimitive(isDeleted)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun SyncedFolder.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"uriString" to JsonPrimitive(uriString),
|
||||
"name" to JsonPrimitive(name),
|
||||
"lastScanTime" to JsonPrimitive(lastScanTime),
|
||||
"allowedFileTypes" to allowedFileTypes
|
||||
.filter { it in SharedFileCapabilities.knownFileTypes }
|
||||
.map { it.name }
|
||||
.sorted()
|
||||
.asJsonArray(),
|
||||
"localSyncEnabled" to JsonPrimitive(localSyncEnabled)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun CustomAppTheme.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"name" to JsonPrimitive(name),
|
||||
"seedColor" to JsonPrimitive(seedColor.toArgb())
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderTheme.toJsonObject(): JsonObject {
|
||||
val values = mutableMapOf<String, JsonElement>(
|
||||
"id" to JsonPrimitive(id),
|
||||
"name" to JsonPrimitive(name),
|
||||
"bgColor" to JsonPrimitive(backgroundColor.toArgb()),
|
||||
"textColor" to JsonPrimitive(textColor.toArgb()),
|
||||
"isDark" to JsonPrimitive(isDark)
|
||||
)
|
||||
textureId?.let { values["textureId"] = JsonPrimitive(it) }
|
||||
return JsonObject(values)
|
||||
}
|
||||
|
||||
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
|
||||
private fun Int?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||
private fun Long?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||
private fun Color?.asJson(): JsonElement = this?.let { JsonPrimitive(it.toArgb()) } ?: JsonNull
|
||||
|
||||
private fun List<String>.asJsonArray(): JsonArray {
|
||||
return JsonArray(map { JsonPrimitive(it) })
|
||||
}
|
||||
|
||||
private fun List<Int>.asIntJsonArray(): JsonArray {
|
||||
return JsonArray(map { JsonPrimitive(it) })
|
||||
}
|
||||
|
||||
private fun JsonElement.asReaderSettingsOrNull(): ReaderSettings? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val defaults = ReaderSettings()
|
||||
return ReaderSettings(
|
||||
fontSize = obj.int("fontSize") ?: defaults.fontSize,
|
||||
lineSpacing = obj.float("lineSpacing") ?: defaults.lineSpacing,
|
||||
margin = obj.int("margin") ?: defaults.margin,
|
||||
darkMode = obj.boolean("darkMode", defaults.darkMode),
|
||||
readingMode = obj.string("readingMode")
|
||||
?.let { runCatching { ReaderReadingMode.valueOf(it) }.getOrNull() }
|
||||
?: defaults.readingMode,
|
||||
textAlign = obj.string("textAlign")
|
||||
?.let { runCatching { SharedReaderTextAlign.valueOf(it) }.getOrNull() }
|
||||
?: defaults.textAlign,
|
||||
pageWidth = obj.int("pageWidth") ?: defaults.pageWidth,
|
||||
fontFamily = obj.string("fontFamily") ?: defaults.fontFamily,
|
||||
paragraphSpacing = obj.float("paragraphSpacing") ?: defaults.paragraphSpacing,
|
||||
imageScale = obj.float("imageScale") ?: defaults.imageScale,
|
||||
horizontalMargin = obj.int("horizontalMargin"),
|
||||
verticalMargin = obj.int("verticalMargin"),
|
||||
themeId = obj.string("themeId"),
|
||||
textureId = obj.string("textureId"),
|
||||
textureAlpha = obj.float("textureAlpha") ?: defaults.textureAlpha,
|
||||
customFontPath = obj.string("customFontPath"),
|
||||
backgroundColorArgb = obj.nullableLong("backgroundColorArgb"),
|
||||
textColorArgb = obj.nullableLong("textColorArgb"),
|
||||
systemUiMode = obj.string("systemUiMode")
|
||||
?.let { runCatching { SystemUiMode.valueOf(it) }.getOrNull() }
|
||||
?: defaults.systemUiMode,
|
||||
pageInfoMode = obj.string("pageInfoMode")
|
||||
?.let { runCatching { PageInfoMode.valueOf(it) }.getOrNull() }
|
||||
?: defaults.pageInfoMode,
|
||||
pageInfoPosition = obj.string("pageInfoPosition")
|
||||
?.let { runCatching { PageInfoPosition.valueOf(it) }.getOrNull() }
|
||||
?: defaults.pageInfoPosition,
|
||||
pageSpreadMode = obj.string("pageSpreadMode")
|
||||
?.let { runCatching { ReaderPageSpreadMode.valueOf(it) }.getOrNull() }
|
||||
?: defaults.pageSpreadMode,
|
||||
rightToLeftPagination = obj.boolean("rightToLeftPagination", defaults.rightToLeftPagination),
|
||||
pdfVerticalPageGapVisible = obj.boolean(
|
||||
"pdfVerticalPageGapVisible",
|
||||
defaults.pdfVerticalPageGapVisible
|
||||
),
|
||||
pdfPageNumberOverlayVisible = obj.boolean(
|
||||
"pdfPageNumberOverlayVisible",
|
||||
defaults.pdfPageNumberOverlayVisible
|
||||
),
|
||||
pdfFirstPageStandaloneInSpread = obj.boolean(
|
||||
"pdfFirstPageStandaloneInSpread",
|
||||
defaults.pdfFirstPageStandaloneInSpread
|
||||
),
|
||||
seamlessChapterNavigation = obj.boolean("seamlessChapterNavigation", defaults.seamlessChapterNavigation),
|
||||
chapterTurnDragMultiplier = obj.float("chapterTurnDragMultiplier") ?: defaults.chapterTurnDragMultiplier
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asReaderToolbarPreferencesOrNull(): ReaderToolbarPreferences? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val order = obj.stringArray("toolOrder").mapNotNull(ReaderTool::fromId)
|
||||
val bottomToolIds = if (obj["bottomToolIds"] == null) {
|
||||
ReaderToolbarPreferences.defaultBottomToolIds
|
||||
} else {
|
||||
obj.stringArray("bottomToolIds").toSet()
|
||||
}
|
||||
return ReaderToolbarPreferences(
|
||||
hiddenToolIds = obj.stringArray("hiddenToolIds").toSet(),
|
||||
toolOrder = order.ifEmpty { ReaderTool.entries.toList() },
|
||||
bottomToolIds = bottomToolIds
|
||||
).sanitized()
|
||||
}
|
||||
|
||||
private fun JsonElement.asReaderHighlightPaletteOrNull(): ReaderHighlightPalette? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val colors = obj.stringArray("colorIds")
|
||||
.mapNotNull { colorId -> HighlightColor.entries.firstOrNull { it.id == colorId || it.name == colorId } }
|
||||
return ReaderHighlightPalette(colors = colors).sanitized()
|
||||
}
|
||||
|
||||
private fun JsonElement.asSharedPdfHighlighterPaletteOrNull(): SharedPdfHighlighterPalette? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return SharedPdfHighlighterPalette(colors = obj.intArray("colorsArgb")).sanitized()
|
||||
}
|
||||
|
||||
private fun JsonElement.asSharedPdfReaderViewportOrNull(): SharedPdfReaderViewport? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val defaults = SharedPdfReaderViewport()
|
||||
return SharedPdfReaderViewport(
|
||||
pageIndex = obj.int("pageIndex") ?: defaults.pageIndex,
|
||||
displayMode = obj.string("displayMode")
|
||||
?.let { runCatching { PdfDisplayMode.valueOf(it) }.getOrNull() }
|
||||
?: defaults.displayMode,
|
||||
zoom = obj.float("zoom") ?: defaults.zoom,
|
||||
horizontalScrollOffset = obj.int("horizontalScrollOffset") ?: defaults.horizontalScrollOffset,
|
||||
paginatedVerticalScrollOffset = obj.int("paginatedVerticalScrollOffset")
|
||||
?: defaults.paginatedVerticalScrollOffset,
|
||||
verticalFirstPageIndex = obj.int("verticalFirstPageIndex") ?: defaults.verticalFirstPageIndex,
|
||||
verticalFirstPageScrollOffset = obj.int("verticalFirstPageScrollOffset")
|
||||
?: defaults.verticalFirstPageScrollOffset
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asReaderBookmarkOrNull(): ReaderBookmark? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val pageIndex = obj.int("pageIndex") ?: return null
|
||||
return ReaderBookmark(
|
||||
id = obj.string("id") ?: return null,
|
||||
pageIndex = pageIndex,
|
||||
chapterTitle = obj.string("chapterTitle") ?: "",
|
||||
preview = obj.string("preview") ?: "",
|
||||
locator = obj["locator"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderLocatorOrNull()
|
||||
?.withFallbacks(pageIndex = pageIndex, textQuote = obj.string("preview") ?: "")
|
||||
?: ReaderLocator(
|
||||
pageIndex = pageIndex,
|
||||
textQuote = obj.string("preview") ?: ""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asReaderHighlightOrNull(): UserHighlight? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val cfi = obj.string("cfi") ?: return null
|
||||
val text = obj.string("text") ?: return null
|
||||
val chapterIndex = obj.int("chapterIndex") ?: return null
|
||||
val color = obj.string("colorId")
|
||||
?.let { colorId -> HighlightColor.entries.firstOrNull { it.id == colorId } }
|
||||
?: HighlightColor.YELLOW
|
||||
return UserHighlight(
|
||||
id = obj.string("id") ?: return null,
|
||||
cfi = cfi,
|
||||
text = text,
|
||||
color = color,
|
||||
chapterIndex = chapterIndex,
|
||||
note = obj.string("note")?.takeIf { it.isNotBlank() },
|
||||
locator = obj["locator"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderLocatorOrNull()
|
||||
?.withFallbacks(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
textQuote = text
|
||||
)
|
||||
?: ReaderLocator.fromLegacy(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
textQuote = text
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asReaderLocatorOrNull(): ReaderLocator? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return ReaderLocator(
|
||||
chapterIndex = obj.int("chapterIndex"),
|
||||
chapterId = obj.string("chapterId"),
|
||||
href = obj.string("href"),
|
||||
pageIndex = obj.int("pageIndex"),
|
||||
startOffset = obj.int("startOffset"),
|
||||
endOffset = obj.int("endOffset"),
|
||||
blockIndex = obj.int("blockIndex"),
|
||||
charOffset = obj.int("charOffset"),
|
||||
textQuote = obj.string("textQuote"),
|
||||
cfi = obj.string("cfi")
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderSettings?.asJson(): JsonElement {
|
||||
val settings = this ?: return JsonNull
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"fontSize" to JsonPrimitive(settings.fontSize),
|
||||
"lineSpacing" to JsonPrimitive(settings.lineSpacing),
|
||||
"margin" to JsonPrimitive(settings.margin),
|
||||
"darkMode" to JsonPrimitive(settings.darkMode),
|
||||
"readingMode" to JsonPrimitive(settings.readingMode.name),
|
||||
"textAlign" to JsonPrimitive(settings.textAlign.name),
|
||||
"pageWidth" to JsonPrimitive(settings.pageWidth),
|
||||
"fontFamily" to JsonPrimitive(settings.fontFamily),
|
||||
"paragraphSpacing" to JsonPrimitive(settings.paragraphSpacing),
|
||||
"imageScale" to JsonPrimitive(settings.imageScale),
|
||||
"horizontalMargin" to settings.horizontalMargin.asJson(),
|
||||
"verticalMargin" to settings.verticalMargin.asJson(),
|
||||
"themeId" to settings.themeId.asJson(),
|
||||
"textureId" to settings.textureId.asJson(),
|
||||
"textureAlpha" to JsonPrimitive(settings.textureAlpha),
|
||||
"customFontPath" to settings.customFontPath.asJson(),
|
||||
"backgroundColorArgb" to settings.backgroundColorArgb.asJson(),
|
||||
"textColorArgb" to settings.textColorArgb.asJson(),
|
||||
"systemUiMode" to JsonPrimitive(settings.systemUiMode.name),
|
||||
"pageInfoMode" to JsonPrimitive(settings.pageInfoMode.name),
|
||||
"pageInfoPosition" to JsonPrimitive(settings.pageInfoPosition.name),
|
||||
"pageSpreadMode" to JsonPrimitive(settings.pageSpreadMode.name),
|
||||
"rightToLeftPagination" to JsonPrimitive(settings.rightToLeftPagination),
|
||||
"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)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderToolbarPreferences.toJsonObject(): JsonObject {
|
||||
val sanitized = sanitized()
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"hiddenToolIds" to sanitized.hiddenToolIds.toList().sorted().asJsonArray(),
|
||||
"toolOrder" to sanitized.toolOrder.map { it.id }.asJsonArray(),
|
||||
"bottomToolIds" to sanitized.bottomToolIds.toList().sorted().asJsonArray()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderHighlightPalette.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"colorIds" to sanitized().colors.map { it.id }.asJsonArray()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun SharedPdfHighlighterPalette.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"colorsArgb" to sanitized().colors.asIntJsonArray()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun SharedPdfReaderViewport?.asJson(): JsonElement {
|
||||
val viewport = this ?: return JsonNull
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"pageIndex" to JsonPrimitive(viewport.pageIndex),
|
||||
"displayMode" to JsonPrimitive(viewport.displayMode.name),
|
||||
"zoom" to JsonPrimitive(viewport.zoom),
|
||||
"horizontalScrollOffset" to JsonPrimitive(viewport.horizontalScrollOffset),
|
||||
"paginatedVerticalScrollOffset" to JsonPrimitive(viewport.paginatedVerticalScrollOffset),
|
||||
"verticalFirstPageIndex" to JsonPrimitive(viewport.verticalFirstPageIndex),
|
||||
"verticalFirstPageScrollOffset" to JsonPrimitive(viewport.verticalFirstPageScrollOffset)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderBookmark.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"pageIndex" to JsonPrimitive(pageIndex),
|
||||
"chapterTitle" to JsonPrimitive(chapterTitle),
|
||||
"preview" to JsonPrimitive(preview),
|
||||
"locator" to locator.toJsonObject()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun UserHighlight.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"cfi" to JsonPrimitive(cfi),
|
||||
"text" to JsonPrimitive(text),
|
||||
"colorId" to JsonPrimitive(color.id),
|
||||
"chapterIndex" to JsonPrimitive(chapterIndex),
|
||||
"note" to note.asJson(),
|
||||
"locator" to locator.toJsonObject()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderLocator.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
buildMap {
|
||||
chapterIndex?.let { put("chapterIndex", JsonPrimitive(it)) }
|
||||
chapterId?.let { put("chapterId", JsonPrimitive(it)) }
|
||||
href?.let { put("href", JsonPrimitive(it)) }
|
||||
pageIndex?.let { put("pageIndex", JsonPrimitive(it)) }
|
||||
startOffset?.let { put("startOffset", JsonPrimitive(it)) }
|
||||
endOffset?.let { put("endOffset", JsonPrimitive(it)) }
|
||||
blockIndex?.let { put("blockIndex", JsonPrimitive(it)) }
|
||||
charOffset?.let { put("charOffset", JsonPrimitive(it)) }
|
||||
textQuote?.let { put("textQuote", JsonPrimitive(it)) }
|
||||
cfi?.let { put("cfi", JsonPrimitive(it)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderLocator?.asJson(): JsonElement {
|
||||
return this?.toJsonObject() ?: JsonNull
|
||||
}
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderEngine
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderSessionState
|
||||
|
||||
fun LibraryState.reduce(action: LibraryAction): LibraryState {
|
||||
return when (action) {
|
||||
is LibraryAction.SearchChanged -> copy(searchQuery = action.query)
|
||||
is LibraryAction.SortChanged -> copy(sortOrder = action.sortOrder)
|
||||
is LibraryAction.FiltersChanged -> copy(filters = action.filters)
|
||||
is LibraryAction.BookSelectionToggled -> {
|
||||
val selected = if (action.bookId in selectedBookIds) {
|
||||
selectedBookIds - action.bookId
|
||||
} else {
|
||||
selectedBookIds + action.bookId
|
||||
}
|
||||
copy(selectedBookIds = selected)
|
||||
}
|
||||
is LibraryAction.BookSelectionReplaced -> copy(selectedBookIds = action.bookIds)
|
||||
LibraryAction.SelectionCleared -> copy(selectedBookIds = emptySet())
|
||||
is LibraryAction.ShelfSelectionToggled -> this
|
||||
LibraryAction.ShelfSelectionCleared -> this
|
||||
is LibraryAction.LibraryPageChanged -> this
|
||||
is LibraryAction.RecentLimitChanged -> copy(recentLimit = action.limit)
|
||||
}
|
||||
}
|
||||
|
||||
fun SharedReaderScreenState.reduce(action: LibraryAction): SharedReaderScreenState {
|
||||
return when (action) {
|
||||
is LibraryAction.SearchChanged -> copy(searchQuery = action.query)
|
||||
is LibraryAction.SortChanged -> copy(sortOrder = action.sortOrder)
|
||||
is LibraryAction.FiltersChanged -> copy(libraryFilters = action.filters)
|
||||
is LibraryAction.BookSelectionToggled -> {
|
||||
val selected = if (action.bookId in selectedBookIds) {
|
||||
selectedBookIds - action.bookId
|
||||
} else {
|
||||
selectedBookIds + action.bookId
|
||||
}
|
||||
copy(selectedBookIds = selected)
|
||||
}
|
||||
is LibraryAction.BookSelectionReplaced -> copy(selectedBookIds = action.bookIds)
|
||||
LibraryAction.SelectionCleared -> copy(selectedBookIds = emptySet())
|
||||
is LibraryAction.ShelfSelectionToggled -> {
|
||||
val selected = if (action.shelfId in selectedShelfIds) {
|
||||
selectedShelfIds - action.shelfId
|
||||
} else {
|
||||
selectedShelfIds + action.shelfId
|
||||
}
|
||||
copy(selectedShelfIds = selected)
|
||||
}
|
||||
LibraryAction.ShelfSelectionCleared -> copy(selectedShelfIds = emptySet())
|
||||
is LibraryAction.LibraryPageChanged -> copy(libraryScreenStartPage = action.page)
|
||||
is LibraryAction.RecentLimitChanged -> copy(recentFilesLimit = action.limit)
|
||||
}
|
||||
}
|
||||
|
||||
fun SharedReaderScreenState.replaceBookSelectionWithVisibleBooks(
|
||||
visibleBooks: Collection<BookItem>
|
||||
): SharedReaderScreenState {
|
||||
val visibleIds = visibleBooks.mapTo(linkedSetOf()) { it.id }
|
||||
val action = if (visibleIds.isNotEmpty() && selectedBookIds.containsAll(visibleIds)) {
|
||||
LibraryAction.SelectionCleared
|
||||
} else {
|
||||
LibraryAction.BookSelectionReplaced(visibleIds)
|
||||
}
|
||||
return reduce(action)
|
||||
}
|
||||
|
||||
fun SharedReaderScreenState.reduce(action: AppAction): SharedReaderScreenState {
|
||||
return when (action) {
|
||||
is AppAction.BannerShown -> copy(bannerMessage = action.message)
|
||||
AppAction.BannerDismissed -> copy(bannerMessage = null)
|
||||
is AppAction.NavigationRequested -> this
|
||||
is AppAction.AppThemeChanged -> copy(appThemeMode = action.mode)
|
||||
is AppAction.AppContrastChanged -> copy(appContrastOption = action.option)
|
||||
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)
|
||||
}
|
||||
is AppAction.CustomAppThemeDeleted -> {
|
||||
val updatedThemes = customAppThemes.filterNot { it.id == action.themeId }
|
||||
val shouldClearSeed = appSeedColor != null && updatedThemes.none { it.seedColor == appSeedColor }
|
||||
copy(
|
||||
customAppThemes = updatedThemes,
|
||||
appSeedColor = if (shouldClearSeed) null else appSeedColor
|
||||
)
|
||||
}
|
||||
is AppAction.CustomReaderThemesChanged -> copy(
|
||||
customReaderThemes = action.themes.sanitizeCustomReaderThemes()
|
||||
)
|
||||
is AppAction.SyncEnabledChanged -> copy(isSyncEnabled = action.enabled)
|
||||
is AppAction.FolderSyncEnabledChanged -> copy(isFolderSyncEnabled = action.enabled)
|
||||
is AppAction.TabsEnabledChanged -> copy(
|
||||
isTabsEnabled = action.enabled,
|
||||
openTabIds = if (action.enabled) openTabIds else emptyList(),
|
||||
activeTabBookId = if (action.enabled) activeTabBookId else null
|
||||
)
|
||||
is AppAction.BookTabOpened -> {
|
||||
val bookId = action.bookId.trim()
|
||||
if (bookId.isBlank()) {
|
||||
this
|
||||
} else {
|
||||
val currentTabIds = openTabIds.distinct()
|
||||
val nextTabIds = if (bookId in currentTabIds) currentTabIds else currentTabIds + bookId
|
||||
copy(
|
||||
isTabsEnabled = true,
|
||||
openTabIds = nextTabIds,
|
||||
activeTabBookId = bookId
|
||||
)
|
||||
}
|
||||
}
|
||||
is AppAction.BookTabClosed -> {
|
||||
val remaining = openTabIds.filterNot { it == action.bookId }
|
||||
copy(
|
||||
openTabIds = remaining,
|
||||
activeTabBookId = if (activeTabBookId == action.bookId) remaining.lastOrNull() else activeTabBookId
|
||||
)
|
||||
}
|
||||
AppAction.AllTabsClosed -> copy(openTabIds = emptyList(), activeTabBookId = null)
|
||||
is AppAction.HomePinToggled -> copy(
|
||||
pinnedHomeBookIds = if (action.bookId in pinnedHomeBookIds) {
|
||||
pinnedHomeBookIds - action.bookId
|
||||
} else {
|
||||
pinnedHomeBookIds + action.bookId
|
||||
}
|
||||
)
|
||||
is AppAction.LibraryPinToggled -> copy(
|
||||
pinnedLibraryBookIds = if (action.bookId in pinnedLibraryBookIds) {
|
||||
pinnedLibraryBookIds - action.bookId
|
||||
} else {
|
||||
pinnedLibraryBookIds + action.bookId
|
||||
}
|
||||
)
|
||||
is AppAction.ReaderDefaultSettingsChanged -> copy(
|
||||
readerDefaultSettings = action.settings
|
||||
)
|
||||
is AppAction.PdfReaderDefaultSettingsChanged -> copy(
|
||||
pdfReaderDefaultSettings = action.settings
|
||||
)
|
||||
is AppAction.ReaderToolbarPreferencesChanged -> copy(
|
||||
readerToolbarPreferences = action.preferences.sanitized()
|
||||
)
|
||||
is AppAction.ReaderToolVisibilityChanged -> copy(
|
||||
readerToolbarPreferences = readerToolbarPreferences.withVisibility(action.tool, action.hidden)
|
||||
)
|
||||
is AppAction.ReaderToolPlacementChanged -> copy(
|
||||
readerToolbarPreferences = readerToolbarPreferences.withBottomPlacement(action.tool, action.bottom)
|
||||
)
|
||||
is AppAction.ReaderToolOrderChanged -> copy(
|
||||
readerToolbarPreferences = readerToolbarPreferences.withToolOrder(action.toolOrder)
|
||||
)
|
||||
is AppAction.ReaderHighlightPaletteChanged -> copy(
|
||||
readerHighlightPalette = action.palette.sanitized()
|
||||
)
|
||||
is AppAction.PdfHighlighterPaletteChanged -> copy(
|
||||
pdfHighlighterPalette = action.palette.sanitized()
|
||||
)
|
||||
is AppAction.ReaderTtsReplacementPreferencesChanged -> copy(
|
||||
readerTtsReplacementPreferences = action.preferences
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun ReaderSessionState.reduce(action: ReaderAction, readerEngine: ReaderEngine): ReaderSessionState {
|
||||
return when (action) {
|
||||
ReaderAction.NextPage -> readerEngine.next(this)
|
||||
ReaderAction.PreviousPage -> readerEngine.previous(this)
|
||||
is ReaderAction.GoToPage -> readerEngine.goToPage(this, action.pageIndex)
|
||||
is ReaderAction.GoToPageNumber -> readerEngine.goToPageNumber(this, action.pageNumber)
|
||||
is ReaderAction.GoToProgress -> readerEngine.goToProgress(this, action.progress)
|
||||
is ReaderAction.GoToChapter -> readerEngine.goToChapter(this, action.chapterIndex)
|
||||
is ReaderAction.GoToLocator -> readerEngine.goToLocator(this, action.locator)
|
||||
is ReaderAction.JumpToPage -> readerEngine.jumpToPage(this, action.pageIndex)
|
||||
is ReaderAction.JumpToPageNumber -> readerEngine.jumpToPageNumber(this, action.pageNumber)
|
||||
is ReaderAction.JumpToChapter -> readerEngine.jumpToChapter(this, action.chapterIndex)
|
||||
is ReaderAction.JumpToLocator -> readerEngine.jumpToLocator(this, action.locator)
|
||||
is ReaderAction.VisiblePageChanged -> readerEngine.syncVisiblePage(this, action.pageIndex, action.locator)
|
||||
is ReaderAction.GoToSearchResult -> readerEngine.goToSearchResult(this, action.resultIndex)
|
||||
is ReaderAction.JumpToSearchResult -> readerEngine.jumpToSearchResult(this, action.resultIndex)
|
||||
ReaderAction.JumpToNextSearchResult -> readerEngine.jumpToNextSearchResult(this)
|
||||
ReaderAction.JumpToPreviousSearchResult -> readerEngine.jumpToPreviousSearchResult(this)
|
||||
ReaderAction.JumpBack -> readerEngine.jumpBack(this)
|
||||
ReaderAction.JumpForward -> readerEngine.jumpForward(this)
|
||||
ReaderAction.JumpHistoryCleared -> readerEngine.clearJumpHistory(this)
|
||||
is ReaderAction.SearchChanged -> readerEngine.search(this, action.query)
|
||||
ReaderAction.SearchOpened -> readerEngine.openSearch(this)
|
||||
ReaderAction.SearchClosed -> readerEngine.closeSearch(this)
|
||||
ReaderAction.SearchResultsPanelToggled -> readerEngine.toggleSearchResultsPanel(this)
|
||||
is ReaderAction.SearchOptionsChanged -> readerEngine.updateSearchOptions(this, action.options)
|
||||
ReaderAction.NextSearchResult -> readerEngine.nextSearchResult(this)
|
||||
ReaderAction.PreviousSearchResult -> readerEngine.previousSearchResult(this)
|
||||
ReaderAction.ToggleBookmark -> readerEngine.toggleBookmark(this)
|
||||
is ReaderAction.ToggleBookmarkAtLocator -> readerEngine.toggleBookmarkAtLocator(
|
||||
state = this,
|
||||
locator = action.locator,
|
||||
chapterTitle = action.title,
|
||||
preview = action.preview
|
||||
)
|
||||
is ReaderAction.SettingsChanged -> readerEngine.updateSettings(this, action.settings)
|
||||
is ReaderAction.RenderModeChanged -> readerEngine.updateSettings(
|
||||
this,
|
||||
reader.settings.copy(readingMode = action.renderMode.toReaderReadingMode())
|
||||
)
|
||||
is ReaderAction.ThemeChanged -> readerEngine.updateSettings(this, action.theme.toReaderSettings(reader.settings))
|
||||
is ReaderAction.FormatChanged -> readerEngine.updateSettings(this, action.settings.toReaderSettings(reader.settings))
|
||||
is ReaderAction.HighlightCreated -> readerEngine.upsertHighlight(this, action.highlight)
|
||||
is ReaderAction.HighlightUpdated -> readerEngine.updateHighlight(
|
||||
state = this,
|
||||
highlightId = action.highlightId,
|
||||
color = action.color,
|
||||
note = action.note
|
||||
)
|
||||
is ReaderAction.HighlightDeleted -> readerEngine.deleteHighlight(this, action.highlightId)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
enum class SmartField {
|
||||
TITLE,
|
||||
AUTHOR,
|
||||
PROGRESS,
|
||||
FILE_TYPE,
|
||||
FOLDER,
|
||||
TAG
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class SmartOperator {
|
||||
EQUALS,
|
||||
CONTAINS,
|
||||
GREATER_THAN,
|
||||
LESS_THAN
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SmartRule(
|
||||
val field: SmartField,
|
||||
val operator: SmartOperator,
|
||||
val value: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SmartCollectionDefinition(
|
||||
val matchAll: Boolean = true,
|
||||
val rules: List<SmartRule> = emptyList()
|
||||
)
|
||||
|
||||
object SmartCollectionEngine {
|
||||
private val json = Json {
|
||||
encodeDefaults = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
fun toJson(definition: SmartCollectionDefinition): String = json.encodeToString(definition)
|
||||
|
||||
fun fromJson(rawJson: String?): SmartCollectionDefinition? {
|
||||
if (rawJson.isNullOrBlank()) return null
|
||||
return runCatching {
|
||||
json.decodeFromString<SmartCollectionDefinition>(rawJson)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun evaluate(book: BookItem, definition: SmartCollectionDefinition): Boolean {
|
||||
if (definition.rules.isEmpty()) return false
|
||||
|
||||
val results = definition.rules.map { rule ->
|
||||
when (rule.field) {
|
||||
SmartField.TITLE -> evaluateString(book.title ?: book.displayName, rule)
|
||||
SmartField.AUTHOR -> evaluateString(book.author.orEmpty(), rule)
|
||||
SmartField.FILE_TYPE -> evaluateString(book.type.name, rule)
|
||||
SmartField.FOLDER -> evaluateString(book.sourceFolder.orEmpty(), rule)
|
||||
SmartField.TAG -> evaluateTags(book.tags.map { it.name }, rule)
|
||||
SmartField.PROGRESS -> evaluateNumber(book.progressPercentage ?: 0f, rule)
|
||||
}
|
||||
}
|
||||
return if (definition.matchAll) results.all { it } else results.any { it }
|
||||
}
|
||||
|
||||
private fun evaluateString(target: String, rule: SmartRule): Boolean {
|
||||
return when (rule.operator) {
|
||||
SmartOperator.EQUALS -> target.equals(rule.value, ignoreCase = true)
|
||||
SmartOperator.CONTAINS -> target.contains(rule.value, ignoreCase = true)
|
||||
SmartOperator.GREATER_THAN,
|
||||
SmartOperator.LESS_THAN -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun evaluateNumber(target: Float, rule: SmartRule): Boolean {
|
||||
val ruleValue = rule.value.toFloatOrNull() ?: return false
|
||||
return when (rule.operator) {
|
||||
SmartOperator.EQUALS -> target == ruleValue
|
||||
SmartOperator.GREATER_THAN -> target > ruleValue
|
||||
SmartOperator.LESS_THAN -> target < ruleValue
|
||||
SmartOperator.CONTAINS -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun evaluateTags(tags: List<String>, rule: SmartRule): Boolean {
|
||||
return when (rule.operator) {
|
||||
SmartOperator.EQUALS -> tags.any { it.equals(rule.value, ignoreCase = true) }
|
||||
SmartOperator.CONTAINS -> tags.any { it.contains(rule.value, ignoreCase = true) }
|
||||
SmartOperator.GREATER_THAN,
|
||||
SmartOperator.LESS_THAN -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
package org.dueattendant149.bookreader.shared.opds
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
object SharedOpdsCatalogs {
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
fun defaultCatalogs(idFactory: () -> String): List<OpdsCatalog> {
|
||||
return listOf(
|
||||
OpdsCatalog(
|
||||
id = idFactory(),
|
||||
title = "Project Gutenberg",
|
||||
url = "https://m.gutenberg.org/ebooks.opds/",
|
||||
isDefault = true
|
||||
),
|
||||
OpdsCatalog(
|
||||
id = idFactory(),
|
||||
title = "Standard Ebooks",
|
||||
url = "https://standardebooks.org/feeds/opds",
|
||||
isDefault = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun decode(rawJson: String?): List<OpdsCatalog> {
|
||||
if (rawJson.isNullOrBlank()) return emptyList()
|
||||
return runCatching {
|
||||
json.parseToJsonElement(rawJson)
|
||||
.jsonArray
|
||||
.mapNotNull { it.asCatalogOrNull() }
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
fun decodeOrSeed(rawJson: String?, idFactory: () -> String): List<OpdsCatalog> {
|
||||
return decode(rawJson).ifEmpty { defaultCatalogs(idFactory) }
|
||||
}
|
||||
|
||||
fun encode(catalogs: List<OpdsCatalog>): String {
|
||||
val array = JsonArray(catalogs.map { it.toJsonObject() })
|
||||
return json.encodeToString(JsonElement.serializer(), array)
|
||||
}
|
||||
|
||||
fun addCatalog(
|
||||
catalogs: List<OpdsCatalog>,
|
||||
title: String,
|
||||
url: String,
|
||||
username: String?,
|
||||
password: String?,
|
||||
idFactory: () -> String
|
||||
): List<OpdsCatalog> {
|
||||
val normalizedTitle = title.trim()
|
||||
val normalizedUrl = url.trim()
|
||||
if (normalizedTitle.isBlank() || normalizedUrl.isBlank()) return catalogs
|
||||
return catalogs + OpdsCatalog(
|
||||
id = idFactory(),
|
||||
title = normalizedTitle,
|
||||
url = normalizedUrl,
|
||||
username = username.normalizedCredential(),
|
||||
password = password.normalizedCredential()
|
||||
)
|
||||
}
|
||||
|
||||
fun updateCatalog(
|
||||
catalogs: List<OpdsCatalog>,
|
||||
id: String,
|
||||
title: String,
|
||||
url: String,
|
||||
username: String?,
|
||||
password: String?
|
||||
): List<OpdsCatalog> {
|
||||
return catalogs.map { catalog ->
|
||||
if (catalog.id != id || catalog.isDefault) {
|
||||
catalog
|
||||
} else {
|
||||
catalog.copy(
|
||||
title = title.trim(),
|
||||
url = url.trim(),
|
||||
username = username.normalizedCredential(),
|
||||
password = password.normalizedCredential()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeCatalog(catalogs: List<OpdsCatalog>, id: String): List<OpdsCatalog> {
|
||||
val catalog = catalogs.firstOrNull { it.id == id }
|
||||
if (catalog?.isDefault == true) return catalogs
|
||||
return catalogs.filterNot { it.id == id }
|
||||
}
|
||||
|
||||
private fun JsonElement.asCatalogOrNull(): OpdsCatalog? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val id = obj.string("id") ?: return null
|
||||
val title = obj.string("title") ?: return null
|
||||
val url = obj.string("url") ?: return null
|
||||
return OpdsCatalog(
|
||||
id = id,
|
||||
title = title,
|
||||
url = url,
|
||||
isDefault = obj.boolean("isDefault") ?: false,
|
||||
username = obj.string("username").normalizedCredential(),
|
||||
password = obj.string("password").normalizedCredential()
|
||||
)
|
||||
}
|
||||
|
||||
private fun OpdsCatalog.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
buildMap {
|
||||
put("id", JsonPrimitive(id))
|
||||
put("title", JsonPrimitive(title))
|
||||
put("url", JsonPrimitive(url))
|
||||
put("isDefault", JsonPrimitive(isDefault))
|
||||
put("username", username?.let(::JsonPrimitive) ?: JsonNull)
|
||||
put("password", password?.let(::JsonPrimitive) ?: JsonNull)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonObject.string(name: String): String? {
|
||||
val value = this[name]?.takeUnless { it is JsonNull } ?: return null
|
||||
return runCatching { value.jsonPrimitive.contentOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.boolean(name: String): Boolean? {
|
||||
return runCatching { this[name]?.jsonPrimitive?.booleanOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun String?.normalizedCredential(): String? {
|
||||
return this?.trim()?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
package org.dueattendant149.bookreader.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>()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fun addCatalog(title: String, url: String, username: String?, password: String?): SharedOpdsScreenState {
|
||||
val nextCatalogs = SharedOpdsCatalogs.addCatalog(
|
||||
catalogs = repository.loadCatalogs(),
|
||||
title = title,
|
||||
url = url,
|
||||
username = username,
|
||||
password = password,
|
||||
idFactory = idFactory
|
||||
)
|
||||
repository.saveCatalogs(nextCatalogs)
|
||||
state = state.copy(catalogs = nextCatalogs)
|
||||
return state
|
||||
}
|
||||
|
||||
fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?): SharedOpdsScreenState {
|
||||
val nextCatalogs = SharedOpdsCatalogs.updateCatalog(
|
||||
catalogs = repository.loadCatalogs(),
|
||||
id = id,
|
||||
title = title,
|
||||
url = url,
|
||||
username = username,
|
||||
password = password
|
||||
)
|
||||
repository.saveCatalogs(nextCatalogs)
|
||||
state = state.copy(
|
||||
catalogs = nextCatalogs,
|
||||
currentCatalog = state.currentCatalog?.let { current ->
|
||||
nextCatalogs.firstOrNull { it.id == current.id } ?: current
|
||||
}
|
||||
)
|
||||
return state
|
||||
}
|
||||
|
||||
fun removeCatalog(id: String): SharedOpdsScreenState {
|
||||
val nextCatalogs = SharedOpdsCatalogs.removeCatalog(repository.loadCatalogs(), id)
|
||||
repository.saveCatalogs(nextCatalogs)
|
||||
state = state.copy(catalogs = nextCatalogs)
|
||||
return state
|
||||
}
|
||||
|
||||
suspend fun openCatalog(catalog: OpdsCatalog, emit: (SharedOpdsScreenState) -> Unit) {
|
||||
urlStack.clear()
|
||||
state = state.copy(searchUrlTemplate = null, currentCatalog = catalog)
|
||||
fetchUrl(catalog.url, isPagination = false, emit = emit)
|
||||
}
|
||||
|
||||
suspend fun openFeedUrl(url: String, emit: (SharedOpdsScreenState) -> Unit) {
|
||||
fetchUrl(url, isPagination = false, emit = emit)
|
||||
}
|
||||
|
||||
suspend fun loadNextPage(emit: (SharedOpdsScreenState) -> Unit) {
|
||||
val nextUrl = state.currentFeed?.nextUrl ?: return
|
||||
if (state.isLoading) return
|
||||
fetchUrl(nextUrl, isPagination = true, emit = emit)
|
||||
}
|
||||
|
||||
suspend fun navigateBack(emit: (SharedOpdsScreenState) -> Unit): Boolean {
|
||||
return if (urlStack.size > 1) {
|
||||
urlStack.removeAt(urlStack.lastIndex)
|
||||
val previousUrl = urlStack.removeAt(urlStack.lastIndex)
|
||||
fetchUrl(previousUrl, isPagination = false, emit = emit)
|
||||
true
|
||||
} else {
|
||||
urlStack.clear()
|
||||
state = state.copy(
|
||||
isViewingCatalog = false,
|
||||
currentFeed = null,
|
||||
searchUrlTemplate = null,
|
||||
currentCatalog = null
|
||||
)
|
||||
emit(state)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun search(query: String, emit: (SharedOpdsScreenState) -> Unit) {
|
||||
val searchLink = state.searchUrlTemplate ?: return
|
||||
if (query.isBlank()) return
|
||||
val catalog = state.currentCatalog
|
||||
state = state.copy(isLoading = true, errorMessage = null)
|
||||
emit(state)
|
||||
val finalUrl = runCatching {
|
||||
SharedOpdsSearch.buildSearchUrl(searchLink, query) { openSearchUrl ->
|
||||
repository.getSearchTemplate(openSearchUrl, catalog?.username, catalog?.password)
|
||||
}
|
||||
}.getOrElse { error ->
|
||||
state = state.copy(isLoading = false, errorMessage = searchErrorMessage(error))
|
||||
emit(state)
|
||||
return
|
||||
}
|
||||
fetchUrl(finalUrl, isPagination = false, emit = emit)
|
||||
}
|
||||
|
||||
fun clearError(): SharedOpdsScreenState {
|
||||
state = state.copy(errorMessage = null)
|
||||
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
|
||||
} else {
|
||||
state.downloadingState + (entryId to downloadState)
|
||||
}
|
||||
state = state.copy(downloadingState = nextMap)
|
||||
return state
|
||||
}
|
||||
|
||||
private suspend fun fetchUrl(
|
||||
url: String,
|
||||
isPagination: Boolean,
|
||||
emit: (SharedOpdsScreenState) -> Unit
|
||||
) {
|
||||
val catalog = state.currentCatalog
|
||||
state = state.copy(isLoading = true, errorMessage = null, isViewingCatalog = true)
|
||||
emit(state)
|
||||
|
||||
val result = repository.fetchFeed(url, catalog?.username, catalog?.password)
|
||||
result.onSuccess { newFeed ->
|
||||
val template = newFeed.searchUrl ?: state.searchUrlTemplate
|
||||
state = if (isPagination) {
|
||||
val currentEntries = state.currentFeed?.entries.orEmpty()
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
currentFeed = newFeed.copy(entries = currentEntries + newFeed.entries),
|
||||
searchUrlTemplate = template
|
||||
)
|
||||
} else {
|
||||
if (urlStack.isEmpty() || urlStack.last() != url) {
|
||||
urlStack.add(url)
|
||||
}
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
currentFeed = newFeed,
|
||||
searchUrlTemplate = template
|
||||
)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
state = state.copy(
|
||||
isLoading = false,
|
||||
errorMessage = feedLoadErrorMessage(error)
|
||||
)
|
||||
}
|
||||
emit(state)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
package org.dueattendant149.bookreader.shared.opds
|
||||
|
||||
data class OpdsCatalog(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val url: String,
|
||||
val isDefault: Boolean = false,
|
||||
val username: String? = null,
|
||||
val password: String? = null
|
||||
)
|
||||
|
||||
data class OpdsFacet(
|
||||
val title: String,
|
||||
val group: String,
|
||||
val url: String,
|
||||
val isActive: Boolean
|
||||
)
|
||||
|
||||
data class OpdsFeed(
|
||||
val title: String,
|
||||
val entries: List<OpdsEntry>,
|
||||
val nextUrl: String?,
|
||||
val searchUrl: String? = null,
|
||||
val facets: List<OpdsFacet> = emptyList()
|
||||
)
|
||||
|
||||
data class OpdsAuthor(
|
||||
val name: String,
|
||||
val url: String?
|
||||
)
|
||||
|
||||
data class OpdsAcquisition(
|
||||
val url: String,
|
||||
val mimeType: String
|
||||
) {
|
||||
val formatName: String
|
||||
get() = when {
|
||||
mimeType.contains("epub", ignoreCase = true) -> "EPUB"
|
||||
mimeType.contains("pdf", ignoreCase = true) -> "PDF"
|
||||
mimeType.contains("presentationml.presentation", ignoreCase = true) ||
|
||||
mimeType.contains("pptx", ignoreCase = true) -> "PPTX"
|
||||
mimeType.contains("markdown", ignoreCase = true) ||
|
||||
mimeType.contains("text/x-markdown", ignoreCase = true) -> "MD"
|
||||
mimeType.contains("html", ignoreCase = true) ||
|
||||
mimeType.contains("xhtml", ignoreCase = true) -> "HTML"
|
||||
mimeType.contains("mobi", ignoreCase = true) ||
|
||||
mimeType.contains("x-mobipocket-ebook", ignoreCase = true) -> "MOBI"
|
||||
mimeType.contains("fictionbook", ignoreCase = true) ||
|
||||
mimeType.contains("fb2", ignoreCase = true) -> "FB2"
|
||||
mimeType.contains("cbt", ignoreCase = true) ||
|
||||
mimeType.contains("comicbook+tar", ignoreCase = true) ||
|
||||
mimeType.contains("x-tar", ignoreCase = true) ||
|
||||
mimeType.equals("application/tar", ignoreCase = true) -> "CBT"
|
||||
mimeType.contains("cbr", ignoreCase = true) ||
|
||||
mimeType.contains("comicbook-rar", ignoreCase = true) ||
|
||||
mimeType.contains("rar", ignoreCase = true) -> "CBR"
|
||||
mimeType.contains("cb7", ignoreCase = true) ||
|
||||
mimeType.contains("7z", ignoreCase = true) -> "CB7"
|
||||
mimeType.contains("cbz", ignoreCase = true) ||
|
||||
mimeType.contains("comicbook+zip", ignoreCase = true) ||
|
||||
mimeType.contains("comicbook", ignoreCase = true) -> "CBZ"
|
||||
mimeType.contains("txt", ignoreCase = true) ||
|
||||
mimeType.contains("text/plain", ignoreCase = true) -> "TXT"
|
||||
else -> mimeType.substringAfterLast("/").uppercase()
|
||||
}
|
||||
|
||||
val priority: Int
|
||||
get() = when (formatName) {
|
||||
"EPUB" -> 5
|
||||
"PDF" -> 4
|
||||
"PPTX" -> 4
|
||||
"MOBI" -> 3
|
||||
"FB2", "MD", "HTML" -> 2
|
||||
"CBZ", "CBR", "CB7", "CBT" -> 1
|
||||
"TXT" -> 0
|
||||
else -> -1
|
||||
}
|
||||
}
|
||||
|
||||
data class OpdsEntry(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val summary: String?,
|
||||
val authors: List<OpdsAuthor> = emptyList(),
|
||||
val coverUrl: String?,
|
||||
val acquisitions: List<OpdsAcquisition> = emptyList(),
|
||||
val navigationUrl: String?,
|
||||
val publisher: String? = null,
|
||||
val published: String? = null,
|
||||
val language: String? = null,
|
||||
val series: String? = null,
|
||||
val seriesIndex: String? = null,
|
||||
val categories: List<String> = emptyList(),
|
||||
val pseCount: Int? = null,
|
||||
val pseUrlTemplate: String? = null
|
||||
) {
|
||||
val author: String?
|
||||
get() = authors.firstOrNull()?.name
|
||||
|
||||
val bestAcquisition: OpdsAcquisition?
|
||||
get() = acquisitions.maxByOrNull { it.priority }
|
||||
|
||||
val isAcquisition: Boolean
|
||||
get() = acquisitions.isNotEmpty()
|
||||
|
||||
val isNavigation: Boolean
|
||||
get() = navigationUrl != null && acquisitions.isEmpty()
|
||||
|
||||
val isStreamable: Boolean
|
||||
get() = pseUrlTemplate != null && pseCount != null && pseCount > 0
|
||||
}
|
||||
|
||||
data class SharedOpdsDownloadState(
|
||||
val isDownloading: Boolean,
|
||||
val progress: Float? = null
|
||||
)
|
||||
|
||||
data class SharedOpdsScreenState(
|
||||
val catalogs: List<OpdsCatalog> = emptyList(),
|
||||
val currentCatalog: OpdsCatalog? = null,
|
||||
val currentFeed: OpdsFeed? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val isViewingCatalog: Boolean = false,
|
||||
val searchUrlTemplate: String? = null,
|
||||
val downloadingState: Map<String, SharedOpdsDownloadState> = emptyMap()
|
||||
)
|
||||
|
||||
data class OpdsStreamReference(
|
||||
val id: String,
|
||||
val count: Int,
|
||||
val urlTemplate: String,
|
||||
val catalogId: String? = null
|
||||
)
|
||||
|
||||
interface SharedOpdsRepository {
|
||||
fun loadCatalogs(): List<OpdsCatalog>
|
||||
fun saveCatalogs(catalogs: List<OpdsCatalog>)
|
||||
suspend fun fetchFeed(url: String, username: String? = null, password: String? = null): Result<OpdsFeed>
|
||||
suspend fun getSearchTemplate(openSearchUrl: String, username: String? = null, password: String? = null): String?
|
||||
}
|
||||
|
|
@ -0,0 +1,549 @@
|
|||
package org.dueattendant149.bookreader.shared.opds
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.parser.Parser
|
||||
import java.net.URL
|
||||
import java.util.UUID
|
||||
|
||||
class SharedOpdsParser {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
fun parse(bodyString: String, baseUrl: String): OpdsFeed {
|
||||
val trimmed = bodyString.trimStart()
|
||||
return if (trimmed.startsWith("{")) {
|
||||
parseOpds2(trimmed, baseUrl)
|
||||
} else {
|
||||
parseOpds1(trimmed, baseUrl)
|
||||
}
|
||||
}
|
||||
|
||||
fun extractOpenSearchTemplate(bodyString: String, openSearchUrl: String): String? {
|
||||
val document = Jsoup.parse(bodyString, openSearchUrl, Parser.xmlParser())
|
||||
return document.allElements
|
||||
.asSequence()
|
||||
.filter { it.localTagName().equals("url", ignoreCase = true) }
|
||||
.mapNotNull { urlElement ->
|
||||
val type = urlElement.attrAny("type").orEmpty()
|
||||
val template = urlElement.attrAny("template")
|
||||
if (
|
||||
template != null &&
|
||||
(type.contains("atom+xml", ignoreCase = true) || type.contains("opds+xml", ignoreCase = true))
|
||||
) {
|
||||
OpenSearchTemplateCandidate(
|
||||
template = resolveUrl(openSearchUrl, template),
|
||||
priority = openSearchTemplatePriority(type)
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
.maxByOrNull { it.priority }
|
||||
?.template
|
||||
}
|
||||
|
||||
private fun parseOpds2(jsonString: String, baseUrl: String): OpdsFeed {
|
||||
val root = json.parseToJsonElement(jsonString).jsonObject
|
||||
val metadata = root.obj("metadata")
|
||||
val title = metadata?.string("title") ?: "OPDS 2.0 Feed"
|
||||
|
||||
var nextUrl: String? = null
|
||||
var searchUrl: String? = null
|
||||
val facets = mutableListOf<OpdsFacet>()
|
||||
|
||||
root.array("links").forEach { link ->
|
||||
val href = link.string("href")
|
||||
if (!href.isNullOrBlank()) {
|
||||
val resolvedHref = resolveUrl(baseUrl, href)
|
||||
val rels = link.rels()
|
||||
when {
|
||||
"next" in rels -> nextUrl = resolvedHref
|
||||
"search" in rels -> searchUrl = resolvedHref
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
root.array("facets").forEach { facetObj ->
|
||||
val group = facetObj.obj("metadata")?.string("title") ?: "Filter"
|
||||
facetObj.array("links").forEach { link ->
|
||||
val href = link.string("href")
|
||||
if (!href.isNullOrBlank()) {
|
||||
facets.add(
|
||||
OpdsFacet(
|
||||
title = link.string("title") ?: "Facet",
|
||||
group = group,
|
||||
url = resolveUrl(baseUrl, href),
|
||||
isActive = link.obj("properties")?.boolean("active") ?: false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val entries = mutableListOf<OpdsEntry>()
|
||||
root.array("publications").forEach { entries.add(parseOpds2Publication(it, baseUrl)) }
|
||||
root.array("navigation").forEach { entries.add(parseOpds2Navigation(it, baseUrl)) }
|
||||
root.array("groups").forEach { group ->
|
||||
val groupTitle = group.obj("metadata")?.string("title").orEmpty()
|
||||
group.array("navigation").forEach { entries.add(parseOpds2Navigation(it, baseUrl)) }
|
||||
group.array("publications").forEach { entries.add(parseOpds2Publication(it, baseUrl)) }
|
||||
group.array("links").forEach { link ->
|
||||
val href = link.string("href")
|
||||
if (!href.isNullOrBlank()) {
|
||||
entries.add(
|
||||
OpdsEntry(
|
||||
id = href,
|
||||
title = link.string("title") ?: groupTitle,
|
||||
summary = null,
|
||||
authors = emptyList(),
|
||||
coverUrl = null,
|
||||
acquisitions = emptyList(),
|
||||
navigationUrl = resolveUrl(baseUrl, href)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return OpdsFeed(title = title, entries = entries, nextUrl = nextUrl, searchUrl = searchUrl, facets = facets)
|
||||
}
|
||||
|
||||
private fun parseOpds2Publication(pub: JsonObject, baseUrl: String): OpdsEntry {
|
||||
val metadata = pub.obj("metadata")
|
||||
val title = metadata?.string("title") ?: "Unknown Title"
|
||||
val id = metadata?.string("identifier") ?: pub.string("id") ?: UUID.randomUUID().toString()
|
||||
val summary = metadata?.string("description") ?: metadata?.string("summary")
|
||||
val language = metadata?.string("language")
|
||||
val publisher = metadata?.string("publisher")
|
||||
val published = metadata?.string("published")
|
||||
val authors = parseOpds2Authors(metadata?.get("author"), baseUrl)
|
||||
val categories = parseOpds2Categories(metadata?.get("subject"))
|
||||
val (series, seriesIndex) = parseOpds2Series(metadata?.obj("belongsTo"))
|
||||
|
||||
var coverUrl: String? = null
|
||||
var coverPriority = Int.MIN_VALUE
|
||||
pub.array("images").forEach { image ->
|
||||
val href = image.string("href")
|
||||
if (!href.isNullOrBlank()) {
|
||||
val resolvedHref = resolveUrl(baseUrl, href)
|
||||
val priority = image.coverPriority()
|
||||
if (coverUrl == null || priority > coverPriority) {
|
||||
coverUrl = resolvedHref
|
||||
coverPriority = priority
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val acquisitions = mutableListOf<OpdsAcquisition>()
|
||||
var pseCount: Int? = null
|
||||
var pseUrlTemplate: String? = null
|
||||
pub.array("links").forEach { link ->
|
||||
val href = link.string("href")
|
||||
if (!href.isNullOrBlank()) {
|
||||
val rels = link.rels()
|
||||
val type = link.string("type").orEmpty()
|
||||
if (rels.any { it.equals(PSE_STREAM_REL, ignoreCase = true) }) {
|
||||
pseUrlTemplate = resolveUrl(baseUrl, href)
|
||||
pseCount = link.obj("properties")?.int("numberOfItems")?.takeIf { it > 0 }
|
||||
}
|
||||
val coverLinkPriority = coverPriority(rels, type)
|
||||
if (coverLinkPriority != null && coverLinkPriority > coverPriority) {
|
||||
coverUrl = resolveUrl(baseUrl, href)
|
||||
coverPriority = coverLinkPriority
|
||||
}
|
||||
if (rels.any { it.contains("acquisition", ignoreCase = true) } ||
|
||||
(rels.any { it.equals("enclosure", ignoreCase = true) } && type.isDownloadableMediaType())
|
||||
) {
|
||||
acquisitions.add(OpdsAcquisition(resolveUrl(baseUrl, href), type))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return OpdsEntry(
|
||||
id = id,
|
||||
title = title,
|
||||
summary = summary,
|
||||
authors = authors,
|
||||
coverUrl = coverUrl,
|
||||
acquisitions = acquisitions,
|
||||
navigationUrl = null,
|
||||
publisher = publisher,
|
||||
published = published,
|
||||
language = language,
|
||||
series = series,
|
||||
seriesIndex = seriesIndex,
|
||||
categories = categories,
|
||||
pseCount = pseCount,
|
||||
pseUrlTemplate = pseUrlTemplate
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseOpds2Navigation(nav: JsonObject, baseUrl: String): OpdsEntry {
|
||||
val href = nav.string("href")
|
||||
return OpdsEntry(
|
||||
id = href.orEmpty(),
|
||||
title = nav.string("title") ?: "Unknown",
|
||||
summary = nav.string("description"),
|
||||
authors = emptyList(),
|
||||
coverUrl = null,
|
||||
acquisitions = emptyList(),
|
||||
navigationUrl = href?.takeIf { it.isNotBlank() }?.let { resolveUrl(baseUrl, it) }
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseOpds1(xmlString: String, baseUrl: String): OpdsFeed {
|
||||
val document = Jsoup.parse(xmlString, baseUrl, Parser.xmlParser())
|
||||
val feed = document.allElements.firstOrNull { it.localTagName() == "feed" }
|
||||
?: return OpdsFeed("OPDS Feed", emptyList(), nextUrl = null)
|
||||
var title = ""
|
||||
var nextUrl: String? = null
|
||||
var searchUrl: String? = null
|
||||
val entries = mutableListOf<OpdsEntry>()
|
||||
val facets = mutableListOf<OpdsFacet>()
|
||||
|
||||
feed.children().forEach { child ->
|
||||
when (child.localTagName()) {
|
||||
"title" -> title = child.cleanText()
|
||||
"entry" -> entries.add(readOpds1Entry(child, baseUrl))
|
||||
"link" -> {
|
||||
val rel = child.attrAny("rel")
|
||||
val href = child.attrAny("href")
|
||||
val linkTitle = child.attrAny("title")
|
||||
val facetGroup = child.attrAny("opds:facetGroup", "facetGroup") ?: "Filter"
|
||||
val activeFacet = child.attrAny("opds:activeFacet", "activeFacet") == "true"
|
||||
when {
|
||||
rel == "next" -> nextUrl = href?.let { resolveUrl(baseUrl, it) }
|
||||
rel == "search" -> searchUrl = href?.let { resolveUrl(baseUrl, it) }
|
||||
rel == "facet" || rel == "http://opds-spec.org/facet" -> {
|
||||
if (href != null && linkTitle != null) {
|
||||
facets.add(OpdsFacet(linkTitle, facetGroup, resolveUrl(baseUrl, href), activeFacet))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return OpdsFeed(title, entries, nextUrl, searchUrl, facets)
|
||||
}
|
||||
|
||||
private fun readOpds1Entry(entry: Element, baseUrl: String): OpdsEntry {
|
||||
var id = ""
|
||||
var title = ""
|
||||
var summary: String? = null
|
||||
var coverUrl: String? = null
|
||||
var coverPriority = Int.MIN_VALUE
|
||||
var navigationUrl: String? = null
|
||||
var publisher: String? = null
|
||||
var published: String? = null
|
||||
var language: String? = null
|
||||
var series: String? = null
|
||||
var seriesIndex: String? = null
|
||||
var pseCount: Int? = null
|
||||
var pseUrlTemplate: String? = null
|
||||
val authors = mutableListOf<OpdsAuthor>()
|
||||
val categories = mutableListOf<String>()
|
||||
val acquisitions = mutableListOf<OpdsAcquisition>()
|
||||
|
||||
entry.children().forEach { child ->
|
||||
when (val tagName = child.localTagName()) {
|
||||
"id" -> id = child.cleanText()
|
||||
"title" -> title = child.cleanText()
|
||||
"summary", "content" -> summary = child.text().trim()
|
||||
"author" -> authors.add(readOpds1Author(child, baseUrl))
|
||||
"publisher" -> publisher = child.cleanText()
|
||||
"language" -> if (language == null) language = child.cleanText()
|
||||
"issued", "published", "updated" -> {
|
||||
val date = child.cleanText()
|
||||
if (published == null || tagName != "updated") published = date
|
||||
}
|
||||
"category" -> {
|
||||
val category = child.attrAny("label") ?: child.attrAny("term")
|
||||
if (!category.isNullOrBlank()) categories.add(category)
|
||||
}
|
||||
"meta" -> {
|
||||
val property = child.attrAny("property", "name")
|
||||
val content = child.attrAny("content")
|
||||
val textContent = child.cleanText()
|
||||
when (property) {
|
||||
"calibre:series" -> series = content ?: textContent.takeIf { it.isNotBlank() }
|
||||
"calibre:series_index" -> seriesIndex = content ?: textContent.takeIf { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
"link" -> {
|
||||
val rel = child.attrAny("rel").orEmpty()
|
||||
val rels = rel.relTokens()
|
||||
val href = child.attrAny("href").orEmpty()
|
||||
val type = child.attrAny("type").orEmpty()
|
||||
val linkTitle = child.attrAny("title")
|
||||
|
||||
if (rels.any { it.equals(PSE_STREAM_REL, ignoreCase = true) }) {
|
||||
pseUrlTemplate = resolveUrl(baseUrl, href)
|
||||
pseCount = child.attrAny("pse:count", "count")?.toIntOrNull()
|
||||
}
|
||||
|
||||
if (rels.any { it.equals("http://calibre-ebook.com/opds/series", ignoreCase = true) } && series == null) {
|
||||
series = linkTitle
|
||||
}
|
||||
|
||||
if (href.isNotEmpty()) {
|
||||
val absoluteUrl = resolveUrl(baseUrl, href)
|
||||
val linkCoverPriority = coverPriority(rels, type)
|
||||
when {
|
||||
linkCoverPriority != null -> {
|
||||
if (linkCoverPriority > coverPriority) {
|
||||
coverUrl = absoluteUrl
|
||||
coverPriority = linkCoverPriority
|
||||
}
|
||||
}
|
||||
rels.any { it.contains("acquisition", ignoreCase = true) } ||
|
||||
(rels.any { it.equals("enclosure", ignoreCase = true) } && type.isDownloadableMediaType()) -> {
|
||||
acquisitions.add(OpdsAcquisition(absoluteUrl, type))
|
||||
}
|
||||
type.contains("profile=opds-catalog", ignoreCase = true) ||
|
||||
type.contains("application/atom+xml", ignoreCase = true) -> {
|
||||
if (navigationUrl == null) navigationUrl = absoluteUrl
|
||||
}
|
||||
rels.any { it == "subsection" || it == "collection" || it == "start" } -> {
|
||||
if (navigationUrl == null) navigationUrl = absoluteUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return OpdsEntry(
|
||||
id = id,
|
||||
title = title,
|
||||
summary = summary,
|
||||
authors = authors,
|
||||
coverUrl = coverUrl,
|
||||
acquisitions = acquisitions,
|
||||
navigationUrl = navigationUrl,
|
||||
publisher = publisher,
|
||||
published = published,
|
||||
language = language,
|
||||
series = series,
|
||||
seriesIndex = seriesIndex,
|
||||
categories = categories,
|
||||
pseCount = pseCount,
|
||||
pseUrlTemplate = pseUrlTemplate
|
||||
)
|
||||
}
|
||||
|
||||
private fun readOpds1Author(author: Element, baseUrl: String): OpdsAuthor {
|
||||
var name = ""
|
||||
var uri: String? = null
|
||||
author.children().forEach { child ->
|
||||
when (child.localTagName()) {
|
||||
"name" -> name = child.cleanText()
|
||||
"uri" -> uri = resolveUrl(baseUrl, child.cleanText())
|
||||
}
|
||||
}
|
||||
return OpdsAuthor(name, uri)
|
||||
}
|
||||
|
||||
private fun parseOpds2Authors(authorElement: JsonElement?, baseUrl: String): List<OpdsAuthor> {
|
||||
return when (authorElement) {
|
||||
is JsonArray -> authorElement.mapNotNull { parseOpds2Author(it, baseUrl) }
|
||||
null -> emptyList()
|
||||
else -> listOfNotNull(parseOpds2Author(authorElement, baseUrl))
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseOpds2Author(authorElement: JsonElement, baseUrl: String): OpdsAuthor? {
|
||||
authorElement.primitiveString()?.let { return OpdsAuthor(it, null) }
|
||||
val obj = authorElement.asObjectOrNull() ?: return null
|
||||
val name = obj.string("name")?.takeIf { it.isNotBlank() } ?: return null
|
||||
val uri = obj.array("links")
|
||||
.firstOrNull()
|
||||
?.string("href")
|
||||
?.let { resolveUrl(baseUrl, it) }
|
||||
return OpdsAuthor(name, uri)
|
||||
}
|
||||
|
||||
private fun parseOpds2Categories(subjectElement: JsonElement?): List<String> {
|
||||
return when (subjectElement) {
|
||||
is JsonArray -> subjectElement.mapNotNull(::parseOpds2Category)
|
||||
null -> emptyList()
|
||||
else -> listOfNotNull(parseOpds2Category(subjectElement))
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseOpds2Category(subjectElement: JsonElement): String? {
|
||||
subjectElement.primitiveString()?.let { return it }
|
||||
return subjectElement.asObjectOrNull()?.string("name")?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun parseOpds2Series(belongsTo: JsonObject?): Pair<String?, String?> {
|
||||
val seriesElement = belongsTo?.get("series") ?: return null to null
|
||||
val first = if (seriesElement is JsonArray) seriesElement.firstOrNull() else seriesElement
|
||||
first?.primitiveString()?.let { return it to null }
|
||||
val seriesObj = first?.asObjectOrNull() ?: return null to null
|
||||
val name = seriesObj.string("name")
|
||||
val index = seriesObj.get("position")
|
||||
?.jsonPrimitive
|
||||
?.doubleOrNull
|
||||
?.toString()
|
||||
?.removeSuffix(".0")
|
||||
return name to index
|
||||
}
|
||||
|
||||
private fun resolveUrl(baseUrl: String, href: String): String {
|
||||
return runCatching {
|
||||
URL(URL(baseUrl), href).toString()
|
||||
.replace("http://m.gutenberg.org", "https://m.gutenberg.org")
|
||||
.replace("http://www.gutenberg.org", "https://www.gutenberg.org")
|
||||
}.getOrDefault(href)
|
||||
}
|
||||
|
||||
private fun JsonObject.obj(name: String): JsonObject? = get(name)?.asObjectOrNull()
|
||||
|
||||
private fun JsonObject.array(name: String): List<JsonObject> {
|
||||
return runCatching { get(name)?.jsonArray?.mapNotNull { it.asObjectOrNull() }.orEmpty() }
|
||||
.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun JsonObject.string(name: String): String? {
|
||||
return runCatching { get(name)?.jsonPrimitive?.contentOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.boolean(name: String): Boolean? {
|
||||
return runCatching { get(name)?.jsonPrimitive?.contentOrNull?.toBooleanStrictOrNull() }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.int(name: String): Int? {
|
||||
return runCatching { get(name)?.jsonPrimitive?.intOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.coverPriority(): Int {
|
||||
return coverPriority(rels(), string("type").orEmpty()) ?: ImageTypeCoverPriority
|
||||
}
|
||||
|
||||
private fun JsonObject.rels(): List<String> {
|
||||
val rel = get("rel") ?: return emptyList()
|
||||
rel.primitiveString()?.let { return it.relTokens() }
|
||||
return runCatching { rel.jsonArray.mapNotNull { it.primitiveString() } }.getOrDefault(emptyList())
|
||||
.flatMap { it.relTokens() }
|
||||
}
|
||||
|
||||
private fun JsonElement.primitiveString(): String? {
|
||||
return runCatching { jsonPrimitive.contentOrNull }.getOrNull()?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun JsonElement.asObjectOrNull(): JsonObject? {
|
||||
return runCatching { jsonObject }.getOrNull()
|
||||
}
|
||||
|
||||
private fun Element.localTagName(): String = tagName().substringAfter(":")
|
||||
|
||||
private fun Element.cleanText(): String = wholeText().trim().ifBlank { text().trim() }
|
||||
|
||||
private fun Element.attrAny(vararg names: String): String? {
|
||||
names.forEach { name ->
|
||||
val direct = attr(name)
|
||||
if (direct.isNotBlank()) return direct
|
||||
}
|
||||
val localNames = names.map { it.substringAfter(":") }
|
||||
return attributes()
|
||||
.asList()
|
||||
.firstOrNull { attribute ->
|
||||
localNames.any { local -> attribute.key.substringAfter(":").equals(local, ignoreCase = true) }
|
||||
}
|
||||
?.value
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun coverPriority(rel: String, type: String): Int? {
|
||||
return coverPriority(rel.relTokens(), type)
|
||||
}
|
||||
|
||||
private fun coverPriority(rels: List<String>, type: String): Int? {
|
||||
val normalizedRels = rels.map { it.lowercase() }
|
||||
return when {
|
||||
normalizedRels.any { it == "thumbnail" || it.endsWith("/thumbnail") || it.endsWith("/image/thumbnail") } ->
|
||||
ThumbnailCoverPriority
|
||||
normalizedRels.any { it == "cover" || it.endsWith("/cover") || it.contains("/image") } ->
|
||||
GenericCoverPriority
|
||||
type.lowercase().substringBefore(';').trim().startsWith("image/") ->
|
||||
ImageTypeCoverPriority
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.relTokens(): List<String> {
|
||||
return trim()
|
||||
.split(Regex("""\s+"""))
|
||||
.filter { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun openSearchTemplatePriority(type: String): Int {
|
||||
val normalizedType = type.lowercase()
|
||||
return when {
|
||||
normalizedType.contains("profile=opds-catalog") && normalizedType.contains("kind=acquisition") -> 4
|
||||
normalizedType.contains("profile=opds-catalog") -> 3
|
||||
normalizedType.contains("opds+xml") -> 2
|
||||
normalizedType.contains("atom+xml") -> 1
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.isDownloadableMediaType(): Boolean {
|
||||
val normalizedType = lowercase().substringBefore(';').trim()
|
||||
return normalizedType in DownloadableMediaTypes ||
|
||||
DownloadableMediaTypeHints.any { normalizedType.contains(it) }
|
||||
}
|
||||
|
||||
private data class OpenSearchTemplateCandidate(
|
||||
val template: String,
|
||||
val priority: Int
|
||||
)
|
||||
|
||||
private companion object {
|
||||
private const val PSE_STREAM_REL = "http://vaemendis.net/opds-pse/stream"
|
||||
private const val ImageTypeCoverPriority = 1
|
||||
private const val GenericCoverPriority = 2
|
||||
private const val ThumbnailCoverPriority = 3
|
||||
private val DownloadableMediaTypes = setOf(
|
||||
"application/epub+zip",
|
||||
"application/kepub+zip",
|
||||
"application/pdf",
|
||||
"application/x-mobipocket-ebook",
|
||||
"application/vnd.amazon.ebook",
|
||||
"application/vnd.comicbook+zip",
|
||||
"application/vnd.comicbook-rar",
|
||||
"application/x-cbz",
|
||||
"application/x-cbr",
|
||||
"application/x-fictionbook+xml",
|
||||
"application/fb2+zip",
|
||||
"application/xhtml+xml",
|
||||
"text/html",
|
||||
"text/plain",
|
||||
"text/markdown",
|
||||
"text/x-markdown"
|
||||
)
|
||||
private val DownloadableMediaTypeHints = listOf(
|
||||
"epub",
|
||||
"kepub",
|
||||
"mobipocket",
|
||||
"kindle",
|
||||
"azw",
|
||||
"fictionbook",
|
||||
"comicbook"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,317 @@
|
|||
package org.dueattendant149.bookreader.shared.opds
|
||||
|
||||
import org.dueattendant149.bookreader.shared.BookItem
|
||||
import org.dueattendant149.bookreader.shared.SharedFileCapabilities
|
||||
|
||||
object SharedOpdsSearch {
|
||||
suspend fun buildSearchUrl(
|
||||
searchLink: String,
|
||||
query: String,
|
||||
openSearchTemplateResolver: suspend (String) -> String?
|
||||
): String {
|
||||
val template = if (searchLink.hasSearchTemplateToken()) {
|
||||
searchLink
|
||||
} else {
|
||||
openSearchTemplateResolver(searchLink) ?: searchLink
|
||||
}
|
||||
return expandSearchTemplate(template, query)
|
||||
}
|
||||
|
||||
fun expandSearchTemplate(template: String, query: String): String {
|
||||
val encoded = query.percentEncode()
|
||||
val expandedSearchTerms = template
|
||||
.replace("{searchTerms}", encoded)
|
||||
.replace("{count}", DefaultSearchCount)
|
||||
.replace("{startPage}", DefaultSearchStartPage)
|
||||
.replace("{startIndex}", DefaultSearchStartIndex)
|
||||
.replace("{language}", DefaultSearchLanguage)
|
||||
.replace("{inputEncoding}", DefaultSearchEncoding)
|
||||
.replace("{outputEncoding}", DefaultSearchEncoding)
|
||||
if (expandedSearchTerms != template) return expandedSearchTerms
|
||||
|
||||
val queryTemplate = Regex("""\{([?&])([^}]+)\}""").find(template)
|
||||
if (queryTemplate != null) {
|
||||
val operator = queryTemplate.groupValues[1]
|
||||
val variables = queryTemplate.groupValues[2]
|
||||
.split(',')
|
||||
.map { it.substringBefore(':').substringBefore('*').trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
val parameterName = variables.firstOrNull { it.equals("searchTerms", ignoreCase = true) }
|
||||
?: variables.firstOrNull()
|
||||
?: "query"
|
||||
val prefix = template.substringBefore(queryTemplate.value)
|
||||
val suffix = template.substringAfter(queryTemplate.value)
|
||||
val separator = when {
|
||||
operator == "&" -> "&"
|
||||
prefix.contains("?") -> "&"
|
||||
else -> "?"
|
||||
}
|
||||
return "$prefix$separator$parameterName=$encoded$suffix"
|
||||
}
|
||||
|
||||
val expandedQuery = template
|
||||
.replace("{query}", encoded)
|
||||
.replace("{keyword}", encoded)
|
||||
if (expandedQuery != template) return expandedQuery
|
||||
|
||||
val separator = if (template.contains("?")) "&" else "?"
|
||||
return "$template${separator}query=$encoded"
|
||||
}
|
||||
|
||||
private fun String.hasSearchTemplateToken(): Boolean {
|
||||
return contains("{searchTerms}") ||
|
||||
Regex("""\{[?&][^}]+\}""").containsMatchIn(this) ||
|
||||
contains("{query}") ||
|
||||
contains("{keyword}")
|
||||
}
|
||||
|
||||
private const val DefaultSearchCount = "12"
|
||||
private const val DefaultSearchStartPage = "1"
|
||||
private const val DefaultSearchStartIndex = "1"
|
||||
private const val DefaultSearchLanguage = "*"
|
||||
private const val DefaultSearchEncoding = "UTF-8"
|
||||
}
|
||||
|
||||
object SharedOpdsDownloadNamer {
|
||||
fun resolveExtension(
|
||||
acquisition: OpdsAcquisition,
|
||||
contentDisposition: String?,
|
||||
urlPathSegment: String?
|
||||
): String {
|
||||
val candidates = listOfNotNull(
|
||||
extractContentDispositionFilename(contentDisposition),
|
||||
urlPathSegment
|
||||
)
|
||||
|
||||
candidates.forEach { candidate ->
|
||||
extensionSuffixFromName(candidate.percentDecode())?.let { return it }
|
||||
}
|
||||
|
||||
return when (acquisition.formatName) {
|
||||
"EPUB" -> ".epub"
|
||||
"PDF" -> ".pdf"
|
||||
"PPTX" -> ".pptx"
|
||||
"MOBI" -> ".mobi"
|
||||
"FB2" -> ".fb2"
|
||||
"CBZ" -> ".cbz"
|
||||
"CBR" -> ".cbr"
|
||||
"CB7" -> ".cb7"
|
||||
"CBT" -> ".cbt"
|
||||
"MD" -> ".md"
|
||||
"HTML" -> ".html"
|
||||
"TXT" -> ".txt"
|
||||
else -> ".epub"
|
||||
}
|
||||
}
|
||||
|
||||
fun safeFileStem(title: String, fallback: String = "opds_book"): String {
|
||||
val safe = title
|
||||
.replace(Regex("""[^a-zA-Z0-9._-]+"""), "_")
|
||||
.trim('_')
|
||||
.take(80)
|
||||
return safe.ifBlank { fallback }
|
||||
}
|
||||
|
||||
fun extractContentDispositionFilename(contentDisposition: String?): String? {
|
||||
if (contentDisposition.isNullOrBlank()) return null
|
||||
val encodedFilename = Regex("""filename\*=UTF-8''([^;]+)""", RegexOption.IGNORE_CASE)
|
||||
.find(contentDisposition)
|
||||
?.groupValues
|
||||
?.getOrNull(1)
|
||||
if (!encodedFilename.isNullOrBlank()) return encodedFilename.trim('"')
|
||||
|
||||
return Regex("""filename="?([^";]+)"?""", RegexOption.IGNORE_CASE)
|
||||
.find(contentDisposition)
|
||||
?.groupValues
|
||||
?.getOrNull(1)
|
||||
?.trim()
|
||||
?.trim('"')
|
||||
}
|
||||
|
||||
private fun extensionSuffixFromName(fileName: String?): String? {
|
||||
if (fileName.isNullOrBlank()) return null
|
||||
val cleanName = fileName.substringBefore('?').substringBefore('#')
|
||||
val extension = cleanName.substringAfterLast('.', missingDelimiterValue = "")
|
||||
.lowercase()
|
||||
.takeIf { it.isNotBlank() }
|
||||
?: return null
|
||||
if (SharedFileCapabilities.fileTypeForName(cleanName) == org.dueattendant149.bookreader.shared.FileType.UNKNOWN) return null
|
||||
return ".$extension"
|
||||
}
|
||||
}
|
||||
|
||||
object SharedOpdsLocalBookMatcher {
|
||||
fun findBook(entry: OpdsEntry, books: List<BookItem>): BookItem? {
|
||||
return find(
|
||||
entry = entry,
|
||||
books = books,
|
||||
title = { it.title },
|
||||
displayName = { it.displayName },
|
||||
path = { it.path }
|
||||
)
|
||||
}
|
||||
|
||||
fun <T> find(
|
||||
entry: OpdsEntry,
|
||||
books: List<T>,
|
||||
title: (T) -> String?,
|
||||
displayName: (T) -> String?,
|
||||
path: (T) -> String?
|
||||
): T? {
|
||||
val entryKeys = entry.matchKeys()
|
||||
return books.firstOrNull { book ->
|
||||
book.matchKeys(title, displayName, path).any { it in entryKeys }
|
||||
}
|
||||
}
|
||||
|
||||
private fun OpdsEntry.matchKeys(): Set<String> {
|
||||
return buildSet {
|
||||
addNormalized(title)
|
||||
val safeTitle = SharedOpdsDownloadNamer.safeFileStem(title)
|
||||
addNormalized(safeTitle)
|
||||
addNormalized(safeTitle.take(50))
|
||||
acquisitions.forEach { acquisition ->
|
||||
addFileNameKeys(acquisition.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> T.matchKeys(
|
||||
title: (T) -> String?,
|
||||
displayName: (T) -> String?,
|
||||
path: (T) -> String?
|
||||
): Set<String> {
|
||||
return buildSet {
|
||||
addNormalized(title(this@matchKeys))
|
||||
addFileNameKeys(displayName(this@matchKeys))
|
||||
addFileNameKeys(path(this@matchKeys))
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableSet<String>.addFileNameKeys(value: String?) {
|
||||
val decodedName = value
|
||||
?.substringBefore('?')
|
||||
?.substringBefore('#')
|
||||
?.substringAfterLast('/')
|
||||
?.substringAfterLast('\\')
|
||||
?.percentDecode()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: return
|
||||
addNormalized(decodedName)
|
||||
addNormalized(decodedName.withoutKnownExtension())
|
||||
addNormalized(decodedName.withoutKnownExtension().withoutOpdsDownloadPrefix())
|
||||
}
|
||||
|
||||
private fun MutableSet<String>.addNormalized(value: String?) {
|
||||
val normalized = value?.normalizedMatchKey() ?: return
|
||||
if (normalized.isNotBlank()) add(normalized)
|
||||
}
|
||||
|
||||
private fun String.withoutKnownExtension(): String {
|
||||
val knownSuffix = SharedFileCapabilities.fileExtensionSuffixForName(this)
|
||||
if (knownSuffix != null && endsWith(knownSuffix, ignoreCase = true)) {
|
||||
return dropLast(knownSuffix.length)
|
||||
}
|
||||
val extension = substringAfterLast('.', missingDelimiterValue = "")
|
||||
return if (extension.length in 1..8 && extension.all { it.isLetterOrDigit() }) {
|
||||
substringBeforeLast('.')
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.normalizedMatchKey(): String {
|
||||
return percentDecode()
|
||||
.withoutOpdsDownloadPrefix()
|
||||
.replace(Regex("""[^\p{L}\p{N}]+"""), " ")
|
||||
.trim()
|
||||
.lowercase()
|
||||
.replace(Regex("""\s+"""), " ")
|
||||
.removePrefix("opds dl ")
|
||||
}
|
||||
|
||||
private fun String.withoutOpdsDownloadPrefix(): String {
|
||||
return replace(Regex("""^opds[_\-\s]+dl[_\-\s]+""", RegexOption.IGNORE_CASE), "")
|
||||
}
|
||||
}
|
||||
|
||||
object SharedOpdsStreamUri {
|
||||
private const val SCHEME_PREFIX = "opds-pse://stream"
|
||||
|
||||
fun build(reference: OpdsStreamReference): String {
|
||||
return "$SCHEME_PREFIX?id=${reference.id.percentEncode()}" +
|
||||
"&count=${reference.count}" +
|
||||
"&url=${reference.urlTemplate.percentEncode()}" +
|
||||
reference.catalogId?.let { "&catalogId=${it.percentEncode()}" }.orEmpty()
|
||||
}
|
||||
|
||||
fun parse(uriString: String?): OpdsStreamReference? {
|
||||
if (uriString.isNullOrBlank() || !uriString.startsWith(SCHEME_PREFIX)) return null
|
||||
val query = uriString.substringAfter('?', missingDelimiterValue = "")
|
||||
val params = query.split('&')
|
||||
.mapNotNull { pair ->
|
||||
if (pair.isBlank()) return@mapNotNull null
|
||||
val key = pair.substringBefore('=').percentDecode()
|
||||
val value = pair.substringAfter('=', missingDelimiterValue = "").percentDecode()
|
||||
key to value
|
||||
}
|
||||
.toMap()
|
||||
val id = params["id"]?.takeIf { it.isNotBlank() } ?: return null
|
||||
val count = params["count"]?.toIntOrNull()?.takeIf { it > 0 } ?: return null
|
||||
val url = params["url"]?.takeIf { it.isNotBlank() } ?: return null
|
||||
return OpdsStreamReference(
|
||||
id = id,
|
||||
count = count,
|
||||
urlTemplate = url,
|
||||
catalogId = params["catalogId"]?.takeIf { it.isNotBlank() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun String.percentEncode(): String {
|
||||
val bytes = encodeToByteArray()
|
||||
return buildString(bytes.size) {
|
||||
bytes.forEach { byte ->
|
||||
val value = byte.toInt() and 0xFF
|
||||
val char = value.toChar()
|
||||
if (char in 'A'..'Z' || char in 'a'..'z' || char in '0'..'9' || char in "-_.~") {
|
||||
append(char)
|
||||
} else {
|
||||
append('%')
|
||||
append(value.toString(16).uppercase().padStart(2, '0'))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun String.percentDecode(): String {
|
||||
val bytes = mutableListOf<Byte>()
|
||||
var index = 0
|
||||
while (index < length) {
|
||||
val char = this[index]
|
||||
if (char == '%' && index + 2 < length) {
|
||||
val value = substring(index + 1, index + 3).toIntOrNull(16)
|
||||
if (value != null) {
|
||||
bytes += value.toByte()
|
||||
index += 3
|
||||
continue
|
||||
}
|
||||
}
|
||||
val encoded = char.toString().encodeToByteArray()
|
||||
encoded.forEach { bytes += it }
|
||||
index += 1
|
||||
}
|
||||
return bytes.toByteArray().decodeToString()
|
||||
}
|
||||
|
||||
object SharedOpdsText {
|
||||
fun cleanSummary(summary: String?): String {
|
||||
if (summary.isNullOrBlank()) return ""
|
||||
return summary
|
||||
.replace(Regex("""<br\s*/?>""", RegexOption.IGNORE_CASE), "\n")
|
||||
.replace(Regex("""</p\s*>""", RegexOption.IGNORE_CASE), "\n\n")
|
||||
.replace(Regex("""<[^>]+>"""), " ")
|
||||
.replace(Regex("""\s+"""), " ")
|
||||
.trim()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,352 @@
|
|||
package org.dueattendant149.bookreader.shared.pdf
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
enum class PdfAnnotationKind {
|
||||
INK,
|
||||
TEXT,
|
||||
HIGHLIGHT
|
||||
}
|
||||
|
||||
enum class PdfInkTool {
|
||||
NONE,
|
||||
PEN,
|
||||
HIGHLIGHTER,
|
||||
HIGHLIGHTER_ROUND,
|
||||
ERASER,
|
||||
FOUNTAIN_PEN,
|
||||
PENCIL,
|
||||
TEXT
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PdfPagePoint(
|
||||
val x: Float,
|
||||
val y: Float,
|
||||
val timestamp: Long = 0L
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PdfPageBounds(
|
||||
val left: Float,
|
||||
val top: Float,
|
||||
val right: Float,
|
||||
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
|
||||
)
|
||||
|
||||
const val DEFAULT_SHARED_PDF_COMMENT_AUTHOR = "Reader"
|
||||
|
||||
fun List<SharedPdfAnnotationComment>.visiblePdfAnnotationComments(): List<SharedPdfAnnotationComment> {
|
||||
val visibleCommentIds = filter { it.contents.isNotBlank() }.map { it.id }.toSet()
|
||||
return filter { it.contents.isNotBlank() }
|
||||
.map { comment ->
|
||||
if (comment.parentId != null && comment.parentId !in visibleCommentIds) {
|
||||
comment.copy(parentId = null)
|
||||
} else {
|
||||
comment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun List<SharedPdfAnnotationComment>.pdfCommentChildren(parentId: String?): List<SharedPdfAnnotationComment> {
|
||||
return filter { it.parentId == parentId }
|
||||
.sortedWith(compareBy({ it.createdAt.takeIf { timestamp -> timestamp > 0L } ?: Long.MAX_VALUE }, { it.id }))
|
||||
}
|
||||
|
||||
fun List<SharedPdfAnnotationComment>.withoutPdfCommentThread(commentId: String): List<SharedPdfAnnotationComment> {
|
||||
val childrenByParentId = groupBy { it.parentId }
|
||||
val idsToRemove = mutableSetOf<String>()
|
||||
|
||||
fun collect(id: String) {
|
||||
if (!idsToRemove.add(id)) return
|
||||
childrenByParentId[id].orEmpty().forEach { child -> collect(child.id) }
|
||||
}
|
||||
|
||||
collect(commentId)
|
||||
return filterNot { it.id in idsToRemove }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SharedPdfAnnotation(
|
||||
val id: String,
|
||||
val pageIndex: Int,
|
||||
val kind: PdfAnnotationKind,
|
||||
val tool: PdfInkTool = PdfInkTool.PEN,
|
||||
val points: List<PdfPagePoint> = emptyList(),
|
||||
val bounds: PdfPageBounds? = null,
|
||||
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,
|
||||
val fontSize: Float = 16f,
|
||||
val pageRelativeFontSize: Float? = null,
|
||||
val isBold: Boolean = false,
|
||||
val isItalic: Boolean = false,
|
||||
val isUnderline: Boolean = false,
|
||||
val isStrikeThrough: Boolean = false,
|
||||
val fontPath: String? = null,
|
||||
val fontName: String? = null,
|
||||
val rangeStartIndex: Int? = null,
|
||||
val rangeEndIndex: Int? = null,
|
||||
val createdAt: Long = 0L
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SharedPdfEmbeddedAnnotation(
|
||||
val id: String,
|
||||
val pageIndex: Int,
|
||||
val index: Int,
|
||||
val subtype: Int,
|
||||
val bounds: PdfPageBounds,
|
||||
val contents: String = "",
|
||||
val author: String = "",
|
||||
val name: String = "",
|
||||
val inReplyTo: String = "",
|
||||
val replies: List<SharedPdfEmbeddedAnnotation> = emptyList()
|
||||
) {
|
||||
val hasVisibleText: Boolean
|
||||
get() = contents.isNotBlank() || replies.any { it.hasVisibleText }
|
||||
}
|
||||
|
||||
object SharedPdfEmbeddedAnnotationThreads {
|
||||
fun group(
|
||||
annotations: List<SharedPdfEmbeddedAnnotation>,
|
||||
geometryTolerance: Float = 0.02f
|
||||
): List<SharedPdfEmbeddedAnnotation> {
|
||||
if (annotations.isEmpty()) return emptyList()
|
||||
|
||||
val byName = annotations
|
||||
.filter { it.name.isNotBlank() }
|
||||
.associateBy { it.name }
|
||||
val childrenByParentId = mutableMapOf<String, MutableList<SharedPdfEmbeddedAnnotation>>()
|
||||
val roots = mutableListOf<SharedPdfEmbeddedAnnotation>()
|
||||
|
||||
annotations.forEach { annotation ->
|
||||
val parent = byName[annotation.inReplyTo]
|
||||
if (parent != null && parent.id != annotation.id) {
|
||||
childrenByParentId.getOrPut(parent.id) { mutableListOf() } += annotation
|
||||
} else {
|
||||
roots += annotation
|
||||
}
|
||||
}
|
||||
|
||||
fun attachReplies(
|
||||
annotation: SharedPdfEmbeddedAnnotation,
|
||||
visitedIds: Set<String> = emptySet()
|
||||
): SharedPdfEmbeddedAnnotation {
|
||||
if (annotation.id in visitedIds) return annotation.copy(replies = emptyList())
|
||||
val nextVisited = visitedIds + annotation.id
|
||||
val replies = childrenByParentId[annotation.id]
|
||||
.orEmpty()
|
||||
.map { attachReplies(it, nextVisited) }
|
||||
return annotation.copy(replies = annotation.replies + replies)
|
||||
}
|
||||
|
||||
val groupedRoots = mutableListOf<MutableList<SharedPdfEmbeddedAnnotation>>()
|
||||
roots.map { attachReplies(it) }.forEach { annotation ->
|
||||
val group = groupedRoots.firstOrNull { existingGroup ->
|
||||
existingGroup.firstOrNull()?.bounds?.inflatedBy(geometryTolerance)?.intersects(annotation.bounds) == true
|
||||
}
|
||||
if (group == null) {
|
||||
groupedRoots += mutableListOf(annotation)
|
||||
} else {
|
||||
group += annotation
|
||||
}
|
||||
}
|
||||
|
||||
return groupedRoots
|
||||
.mapNotNull { group ->
|
||||
val root = group.firstOrNull() ?: return@mapNotNull null
|
||||
root.copy(replies = root.replies + group.drop(1))
|
||||
}
|
||||
.filter { it.hasVisibleText }
|
||||
}
|
||||
}
|
||||
|
||||
data class PdfToolConfig(
|
||||
val colorArgb: Int,
|
||||
val strokeWidth: Float
|
||||
)
|
||||
|
||||
object SharedPdfAnnotationDefaults {
|
||||
val penPalette: List<Int> = listOf(
|
||||
0xFF000000.toInt(),
|
||||
0xFFFF0000.toInt(),
|
||||
0xFF0000FF.toInt(),
|
||||
0xFF4CAF50.toInt(),
|
||||
0xFFFFFFFF.toInt()
|
||||
)
|
||||
|
||||
val highlighterPalette: List<Int> = SharedPdfAndroidHighlightColors.palette.take(5)
|
||||
|
||||
fun configFor(tool: PdfInkTool): PdfToolConfig {
|
||||
return when (tool) {
|
||||
PdfInkTool.NONE -> PdfToolConfig(0x00000000, 0.008f)
|
||||
PdfInkTool.PEN -> PdfToolConfig(0xFFFF0000.toInt(), 0.008f)
|
||||
PdfInkTool.FOUNTAIN_PEN -> PdfToolConfig(0xFF0000FF.toInt(), 0.008f)
|
||||
PdfInkTool.PENCIL -> PdfToolConfig(0xFF444444.toInt(), 0.008f)
|
||||
PdfInkTool.HIGHLIGHTER -> PdfToolConfig(highlighterPalette[0], 0.035f)
|
||||
PdfInkTool.HIGHLIGHTER_ROUND -> PdfToolConfig(highlighterPalette[1], 0.035f)
|
||||
PdfInkTool.ERASER -> PdfToolConfig(0x00000000, 0.03f)
|
||||
PdfInkTool.TEXT -> PdfToolConfig(0xFF000000.toInt(), 0.02f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class SharedPdfHighlighterPalette(
|
||||
val colors: List<Int> = defaultColors
|
||||
) {
|
||||
fun sanitized(): SharedPdfHighlighterPalette {
|
||||
val normalized = colors
|
||||
.filter { it != 0 }
|
||||
.map { it.withPdfHighlighterAlpha() }
|
||||
.take(MaxColors)
|
||||
val filled = if (normalized.isEmpty()) {
|
||||
defaultColors
|
||||
} else {
|
||||
normalized + defaultColors.drop(normalized.size)
|
||||
}
|
||||
return copy(colors = filled.take(MaxColors))
|
||||
}
|
||||
|
||||
fun withColorAt(slotIndex: Int, colorArgb: Int): SharedPdfHighlighterPalette {
|
||||
val nextColors = sanitized().colors.toMutableList()
|
||||
if (slotIndex !in nextColors.indices) return sanitized()
|
||||
nextColors[slotIndex] = colorArgb.withPdfHighlighterAlpha()
|
||||
return copy(colors = nextColors).sanitized()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DefaultAlpha: Int = 0x8C
|
||||
const val MaxColors: Int = 5
|
||||
val defaultColors: List<Int>
|
||||
get() = SharedPdfAnnotationDefaults.highlighterPalette
|
||||
.take(MaxColors)
|
||||
.map { it.withPdfHighlighterAlpha() }
|
||||
}
|
||||
}
|
||||
|
||||
object SharedPdfAndroidHighlightColors {
|
||||
const val StoredAlpha: Int = 0x8C
|
||||
const val RenderAlpha: Float = 0.4f
|
||||
|
||||
val orderedNames: List<String> = listOf("ORANGE", "YELLOW", "GREEN", "BLUE", "PURPLE")
|
||||
|
||||
val colorsByName: Map<String, Int> = mapOf(
|
||||
"ORANGE" to 0xFFFF9800.toInt(),
|
||||
"YELLOW" to 0xFFFFEB3B.toInt(),
|
||||
"GREEN" to 0xFF81C784.toInt(),
|
||||
"BLUE" to 0xFF64B5F6.toInt(),
|
||||
"PURPLE" to 0xFFE1BEE7.toInt()
|
||||
)
|
||||
|
||||
val palette: List<Int>
|
||||
get() = orderedNames.map(::argbForName)
|
||||
|
||||
fun argbForName(name: String): Int {
|
||||
val opaqueArgb = colorsByName[name.uppercase()] ?: colorsByName.getValue("ORANGE")
|
||||
return (StoredAlpha shl 24) or (opaqueArgb and 0x00FFFFFF)
|
||||
}
|
||||
|
||||
fun nearestName(argb: Int): String {
|
||||
val rgb = argb and 0x00FFFFFF
|
||||
return colorsByName.minByOrNull { (_, color) ->
|
||||
val candidate = color and 0x00FFFFFF
|
||||
val dr = ((rgb shr 16) and 0xFF) - ((candidate shr 16) and 0xFF)
|
||||
val dg = ((rgb shr 8) and 0xFF) - ((candidate shr 8) and 0xFF)
|
||||
val db = (rgb and 0xFF) - (candidate and 0xFF)
|
||||
dr * dr + dg * dg + db * db
|
||||
}?.key ?: "ORANGE"
|
||||
}
|
||||
|
||||
fun nearestArgb(argb: Int): Int {
|
||||
return argbForName(nearestName(argb))
|
||||
}
|
||||
}
|
||||
|
||||
private fun Int.withPdfHighlighterAlpha(): Int {
|
||||
return (SharedPdfHighlighterPalette.DefaultAlpha shl 24) or (this and 0x00FFFFFF)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SharedPdfAnnotationStore(
|
||||
val version: Int = 1,
|
||||
val annotations: List<SharedPdfAnnotation> = emptyList()
|
||||
)
|
||||
|
||||
object SharedPdfAnnotationSerializer {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
fun encode(annotations: List<SharedPdfAnnotation>): String {
|
||||
return json.encodeToString(SharedPdfAnnotationStore(annotations = annotations))
|
||||
}
|
||||
|
||||
fun decode(raw: String): List<SharedPdfAnnotation> {
|
||||
if (raw.isBlank()) return emptyList()
|
||||
return runCatching {
|
||||
json.decodeFromString<SharedPdfAnnotationStore>(raw).annotations
|
||||
}.getOrElse {
|
||||
runCatching { json.decodeFromString<List<SharedPdfAnnotation>>(raw) }.getOrDefault(emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun PdfPageBounds.inflatedBy(amount: Float): PdfPageBounds {
|
||||
return PdfPageBounds(
|
||||
left = (left - amount).coerceAtLeast(0f),
|
||||
top = (top - amount).coerceAtLeast(0f),
|
||||
right = (right + amount).coerceAtMost(1f),
|
||||
bottom = (bottom + amount).coerceAtMost(1f)
|
||||
)
|
||||
}
|
||||
|
||||
private fun PdfPageBounds.intersects(other: PdfPageBounds): Boolean {
|
||||
return left <= other.right &&
|
||||
right >= other.left &&
|
||||
top <= other.bottom &&
|
||||
bottom >= other.top
|
||||
}
|
||||
|
||||
data class PdfZoomSpec(
|
||||
val min: Float = 0.65f,
|
||||
val max: Float = 3.0f,
|
||||
val default: Float = 1.35f,
|
||||
val maxRenderPixels: Int = 18_000_000
|
||||
) {
|
||||
fun clamp(value: Float): Float = value.coerceIn(min, max)
|
||||
|
||||
fun safeRenderScale(pageWidth: Float, pageHeight: Float, requestedScale: Float): Float {
|
||||
val clamped = clamp(requestedScale)
|
||||
val pixelCount = pageWidth * pageHeight * clamped * clamped
|
||||
if (pixelCount <= maxRenderPixels) return clamped
|
||||
val fitScale = kotlin.math.sqrt(maxRenderPixels / (pageWidth * pageHeight))
|
||||
return fitScale.coerceAtMost(clamped).coerceAtLeast(0.1f)
|
||||
}
|
||||
|
||||
fun renderSize(pageWidth: Float, pageHeight: Float, requestedScale: Float): Pair<Int, Int> {
|
||||
val renderScale = safeRenderScale(pageWidth, pageHeight, requestedScale)
|
||||
return (pageWidth * renderScale).roundToInt().coerceAtLeast(1) to
|
||||
(pageHeight * renderScale).roundToInt().coerceAtLeast(1)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,791 @@
|
|||
package org.dueattendant149.bookreader.shared.pdf
|
||||
|
||||
import org.dueattendant149.bookreader.shared.PdfDisplayMode
|
||||
import org.dueattendant149.bookreader.shared.SearchHighlightMode
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
data class SharedPdfSearchResult(
|
||||
val pageIndex: Int,
|
||||
val preview: String,
|
||||
val matchIndex: Int,
|
||||
val matchLength: Int = 0
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SharedPdfBookmark(
|
||||
val pageIndex: Int,
|
||||
val label: String = "",
|
||||
val createdAt: Long = 0L
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SharedPdfBookmarkStore(
|
||||
val version: Int = 1,
|
||||
val bookmarks: List<SharedPdfBookmark> = emptyList()
|
||||
)
|
||||
|
||||
object SharedPdfBookmarkSerializer {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
fun encode(bookmarks: List<SharedPdfBookmark>): String {
|
||||
return json.encodeToString(SharedPdfBookmarkStore(bookmarks = bookmarks))
|
||||
}
|
||||
|
||||
fun decode(raw: String): List<SharedPdfBookmark> {
|
||||
if (raw.isBlank()) return emptyList()
|
||||
return runCatching {
|
||||
json.decodeFromString<SharedPdfBookmarkStore>(raw).bookmarks
|
||||
}.getOrElse {
|
||||
runCatching { json.decodeFromString<List<SharedPdfBookmark>>(raw) }.getOrDefault(emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class SharedPdfJumpHistory(
|
||||
val pages: List<Int> = emptyList(),
|
||||
val cursor: Int = -1,
|
||||
val maxEntries: Int = 21
|
||||
) {
|
||||
val backPage: Int? get() = pages.getOrNull(cursor - 1)
|
||||
val forwardPage: Int? get() = pages.getOrNull(cursor + 1)
|
||||
val hasJumpTargets: Boolean get() = backPage != null || forwardPage != null
|
||||
|
||||
fun record(
|
||||
currentPageIndex: Int,
|
||||
targetPageIndex: Int,
|
||||
pageCount: Int
|
||||
): SharedPdfJumpHistory {
|
||||
if (
|
||||
pageCount <= 0 ||
|
||||
currentPageIndex !in 0 until pageCount ||
|
||||
targetPageIndex !in 0 until pageCount ||
|
||||
currentPageIndex == targetPageIndex
|
||||
) {
|
||||
return this
|
||||
}
|
||||
|
||||
val pruned = pruned(pageCount)
|
||||
val nextPages = pruned.pages.toMutableList()
|
||||
var nextCursor = pruned.cursor
|
||||
|
||||
while (nextPages.lastIndex > nextCursor) {
|
||||
nextPages.removeAt(nextPages.lastIndex)
|
||||
}
|
||||
|
||||
if (nextCursor > 0 && nextPages.getOrNull(nextCursor - 1) == currentPageIndex) {
|
||||
nextPages[nextCursor] = targetPageIndex
|
||||
return copy(
|
||||
pages = nextPages,
|
||||
cursor = nextCursor
|
||||
).bounded()
|
||||
}
|
||||
|
||||
if (nextCursor == -1 || nextPages.getOrNull(nextCursor) != currentPageIndex) {
|
||||
nextPages += currentPageIndex
|
||||
nextCursor = nextPages.lastIndex
|
||||
}
|
||||
|
||||
if (nextPages.lastOrNull() != targetPageIndex) {
|
||||
nextPages += targetPageIndex
|
||||
nextCursor = nextPages.lastIndex
|
||||
}
|
||||
|
||||
return copy(
|
||||
pages = nextPages,
|
||||
cursor = nextCursor
|
||||
).bounded()
|
||||
}
|
||||
|
||||
fun pruned(pageCount: Int): SharedPdfJumpHistory {
|
||||
if (pageCount <= 0) return clear()
|
||||
val nextPages = pages.toMutableList()
|
||||
var nextCursor = cursor
|
||||
var index = nextPages.lastIndex
|
||||
while (index >= 0) {
|
||||
if (nextPages[index] !in 0 until pageCount) {
|
||||
nextPages.removeAt(index)
|
||||
if (nextCursor >= index) nextCursor--
|
||||
}
|
||||
index--
|
||||
}
|
||||
return copy(
|
||||
pages = nextPages,
|
||||
cursor = nextCursor.coerceIn(-1, nextPages.lastIndex)
|
||||
).bounded()
|
||||
}
|
||||
|
||||
fun stepBack(): SharedPdfJumpHistory {
|
||||
return if (backPage == null) this else copy(cursor = (cursor - 1).coerceAtLeast(0))
|
||||
}
|
||||
|
||||
fun stepForward(): SharedPdfJumpHistory {
|
||||
return if (forwardPage == null) this else copy(cursor = (cursor + 1).coerceAtMost(pages.lastIndex))
|
||||
}
|
||||
|
||||
fun clear(): SharedPdfJumpHistory = copy(pages = emptyList(), cursor = -1)
|
||||
|
||||
private fun bounded(): SharedPdfJumpHistory {
|
||||
val safeMaxEntries = maxEntries.coerceAtLeast(2)
|
||||
if (pages.size <= safeMaxEntries) {
|
||||
return copy(cursor = cursor.coerceIn(-1, pages.lastIndex))
|
||||
}
|
||||
val overflow = pages.size - safeMaxEntries
|
||||
return copy(
|
||||
pages = pages.drop(overflow),
|
||||
cursor = (cursor - overflow).coerceIn(-1, pages.size - overflow - 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class SharedPdfReaderViewport(
|
||||
val pageIndex: Int = 0,
|
||||
val displayMode: PdfDisplayMode = PdfDisplayMode.PAGINATION,
|
||||
val zoom: Float = PdfZoomSpec().default,
|
||||
val horizontalScrollOffset: Int = 0,
|
||||
val paginatedVerticalScrollOffset: Int = 0,
|
||||
val verticalFirstPageIndex: Int = pageIndex,
|
||||
val verticalFirstPageScrollOffset: Int = 0
|
||||
) {
|
||||
fun sanitized(
|
||||
pageCount: Int,
|
||||
zoomSpec: PdfZoomSpec = PdfZoomSpec()
|
||||
): SharedPdfReaderViewport {
|
||||
val lastPageIndex = (pageCount.coerceAtLeast(0) - 1).coerceAtLeast(0)
|
||||
val safeZoom = if (zoom.isFinite() && zoom > 0f) zoom else zoomSpec.default
|
||||
return copy(
|
||||
pageIndex = pageIndex.coerceIn(0, lastPageIndex),
|
||||
zoom = zoomSpec.clamp(safeZoom),
|
||||
horizontalScrollOffset = horizontalScrollOffset.coerceAtLeast(0),
|
||||
paginatedVerticalScrollOffset = paginatedVerticalScrollOffset.coerceAtLeast(0),
|
||||
verticalFirstPageIndex = verticalFirstPageIndex.coerceIn(0, lastPageIndex),
|
||||
verticalFirstPageScrollOffset = verticalFirstPageScrollOffset.coerceAtLeast(0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class SharedPdfReaderState(
|
||||
val pageIndex: Int = 0,
|
||||
val pageCount: Int = 0,
|
||||
val displayMode: PdfDisplayMode = PdfDisplayMode.PAGINATION,
|
||||
val zoom: Float = PdfZoomSpec().default,
|
||||
val isSearchActive: Boolean = false,
|
||||
val showSearchResultsPanel: Boolean = true,
|
||||
val searchQuery: String = "",
|
||||
val activeSearchResultIndex: Int = -1,
|
||||
val searchHighlightMode: SearchHighlightMode = SearchHighlightMode.ALL,
|
||||
val selectedTool: PdfInkTool = PdfInkTool.NONE,
|
||||
val selectedColorArgb: Int = SharedPdfAnnotationDefaults.configFor(PdfInkTool.NONE).colorArgb,
|
||||
val strokeWidth: Float = SharedPdfAnnotationDefaults.configFor(PdfInkTool.NONE).strokeWidth,
|
||||
val isTextSelectionMode: Boolean = false,
|
||||
val bookmarks: List<SharedPdfBookmark> = emptyList(),
|
||||
val selectedAnnotationId: String? = null,
|
||||
val annotations: List<SharedPdfAnnotation> = emptyList(),
|
||||
val toolConfigs: Map<PdfInkTool, PdfToolConfig> = emptyMap(),
|
||||
val penPalette: List<Int> = SharedPdfAnnotationDefaults.penPalette,
|
||||
val lastActivePenTool: PdfInkTool = PdfInkTool.PEN,
|
||||
val lastActiveHighlighterTool: PdfInkTool = PdfInkTool.HIGHLIGHTER,
|
||||
val annotationUndoStack: List<SharedPdfAnnotationHistoryAction> = emptyList(),
|
||||
val annotationRedoStack: List<SharedPdfAnnotationHistoryAction> = emptyList()
|
||||
) {
|
||||
val safePageCount: Int get() = pageCount.coerceAtLeast(0)
|
||||
val lastPageIndex: Int get() = (safePageCount - 1).coerceAtLeast(0)
|
||||
val canGoPrevious: Boolean get() = pageIndex > 0
|
||||
val canGoNext: Boolean get() = pageIndex < lastPageIndex
|
||||
val canUndoAnnotationEdit: Boolean get() = annotationUndoStack.isNotEmpty()
|
||||
val canRedoAnnotationEdit: Boolean get() = annotationRedoStack.isNotEmpty()
|
||||
val progressPercent: Float get() = ((pageIndex + 1).toFloat() / safePageCount.coerceAtLeast(1)) * 100f
|
||||
|
||||
fun coerced(zoomSpec: PdfZoomSpec = PdfZoomSpec()): SharedPdfReaderState {
|
||||
val safePage = pageIndex.coerceIn(0, lastPageIndex)
|
||||
return copy(
|
||||
pageIndex = safePage,
|
||||
pageCount = safePageCount,
|
||||
activeSearchResultIndex = activeSearchResultIndex.coerceAtLeast(-1),
|
||||
zoom = zoomSpec.clamp(zoom),
|
||||
bookmarks = bookmarks.normalizedBookmarks(lastPageIndex),
|
||||
penPalette = penPalette.sanitizedSharedPdfPenPalette(),
|
||||
lastActivePenTool = lastActivePenTool.takeIf { it.isSharedPdfPenTool } ?: PdfInkTool.PEN,
|
||||
lastActiveHighlighterTool = lastActiveHighlighterTool.takeIf { it.isSharedPdfHighlighterTool }
|
||||
?: PdfInkTool.HIGHLIGHTER,
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { selectedId ->
|
||||
annotations.any { it.id == selectedId }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun initial(
|
||||
pageCount: Int,
|
||||
initialPageIndex: Int = 0,
|
||||
zoomSpec: PdfZoomSpec = PdfZoomSpec()
|
||||
): SharedPdfReaderState {
|
||||
val safePageCount = pageCount.coerceAtLeast(0)
|
||||
val lastPageIndex = (safePageCount - 1).coerceAtLeast(0)
|
||||
return SharedPdfReaderState(
|
||||
pageIndex = initialPageIndex.coerceIn(0, lastPageIndex),
|
||||
pageCount = safePageCount,
|
||||
zoom = zoomSpec.clamp(zoomSpec.default)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface SharedPdfAnnotationHistoryAction {
|
||||
data class Add(val pageIndex: Int, val annotation: SharedPdfAnnotation) : SharedPdfAnnotationHistoryAction
|
||||
data class Remove(val itemsByPage: Map<Int, List<SharedPdfAnnotation>>) : SharedPdfAnnotationHistoryAction
|
||||
}
|
||||
|
||||
sealed interface SharedPdfReaderAction {
|
||||
data class GoToPage(val pageIndex: Int) : SharedPdfReaderAction
|
||||
data object PreviousPage : SharedPdfReaderAction
|
||||
data object NextPage : SharedPdfReaderAction
|
||||
data object FirstPage : SharedPdfReaderAction
|
||||
data object LastPage : SharedPdfReaderAction
|
||||
data class DisplayModeChanged(val mode: PdfDisplayMode) : SharedPdfReaderAction
|
||||
data object DisplayModeToggled : SharedPdfReaderAction
|
||||
data class ZoomChanged(val zoom: Float) : SharedPdfReaderAction
|
||||
data class ZoomBy(val delta: Float) : SharedPdfReaderAction
|
||||
data class SearchChanged(val query: String) : SharedPdfReaderAction
|
||||
data object SearchOpened : SharedPdfReaderAction
|
||||
data object SearchClosed : SharedPdfReaderAction
|
||||
data object SearchResultsPanelToggled : SharedPdfReaderAction
|
||||
data class SearchHighlightModeChanged(val mode: SearchHighlightMode) : SharedPdfReaderAction
|
||||
data object SearchHighlightModeToggled : SharedPdfReaderAction
|
||||
data class GoToSearchResult(
|
||||
val resultIndex: Int,
|
||||
val results: List<SharedPdfSearchResult>
|
||||
) : SharedPdfReaderAction
|
||||
data class ToolSelected(val tool: PdfInkTool) : SharedPdfReaderAction
|
||||
data class ColorSelected(val colorArgb: Int) : SharedPdfReaderAction
|
||||
data class StrokeWidthChanged(val strokeWidth: Float) : SharedPdfReaderAction
|
||||
data class PenPaletteChanged(val colors: List<Int>) : SharedPdfReaderAction
|
||||
data class TextSelectionModeChanged(val enabled: Boolean) : SharedPdfReaderAction
|
||||
data class BookmarksLoaded(val bookmarks: List<SharedPdfBookmark>) : SharedPdfReaderAction
|
||||
data class BookmarkToggled(
|
||||
val pageIndex: Int,
|
||||
val label: String = "",
|
||||
val createdAt: Long = 0L
|
||||
) : SharedPdfReaderAction
|
||||
data class AnnotationsLoaded(val annotations: List<SharedPdfAnnotation>) : SharedPdfReaderAction
|
||||
data class AnnotationAdded(val annotation: SharedPdfAnnotation) : SharedPdfReaderAction
|
||||
data class AnnotationSelected(val annotationId: String?) : SharedPdfReaderAction
|
||||
data class AnnotationUpdated(val annotation: SharedPdfAnnotation) : SharedPdfReaderAction
|
||||
data class AnnotationDeleted(val annotationId: String) : SharedPdfReaderAction
|
||||
data class AnnotationsChanged(val annotations: List<SharedPdfAnnotation>) : SharedPdfReaderAction
|
||||
data class UndoLastAnnotationOnPage(val pageIndex: Int) : SharedPdfReaderAction
|
||||
data object UndoAnnotationEdit : SharedPdfReaderAction
|
||||
data object RedoAnnotationEdit : SharedPdfReaderAction
|
||||
data class ClearPageAnnotations(val pageIndex: Int) : SharedPdfReaderAction
|
||||
}
|
||||
|
||||
fun SharedPdfReaderState.reduce(
|
||||
action: SharedPdfReaderAction,
|
||||
zoomSpec: PdfZoomSpec = PdfZoomSpec()
|
||||
): SharedPdfReaderState {
|
||||
fun goToPage(target: Int): SharedPdfReaderState {
|
||||
return copy(pageIndex = target.coerceIn(0, lastPageIndex)).coerced(zoomSpec)
|
||||
}
|
||||
|
||||
return when (action) {
|
||||
is SharedPdfReaderAction.GoToPage -> goToPage(action.pageIndex)
|
||||
SharedPdfReaderAction.PreviousPage -> goToPage(pageIndex - 1)
|
||||
SharedPdfReaderAction.NextPage -> goToPage(pageIndex + 1)
|
||||
SharedPdfReaderAction.FirstPage -> goToPage(0)
|
||||
SharedPdfReaderAction.LastPage -> goToPage(lastPageIndex)
|
||||
is SharedPdfReaderAction.DisplayModeChanged -> copy(displayMode = action.mode)
|
||||
SharedPdfReaderAction.DisplayModeToggled -> copy(
|
||||
displayMode = when (displayMode) {
|
||||
PdfDisplayMode.PAGINATION -> PdfDisplayMode.VERTICAL_SCROLL
|
||||
PdfDisplayMode.VERTICAL_SCROLL -> PdfDisplayMode.PAGINATION
|
||||
}
|
||||
)
|
||||
is SharedPdfReaderAction.ZoomChanged -> copy(zoom = zoomSpec.clamp(action.zoom))
|
||||
is SharedPdfReaderAction.ZoomBy -> copy(zoom = zoomSpec.clamp(zoom + action.delta))
|
||||
is SharedPdfReaderAction.SearchChanged -> {
|
||||
val normalized = action.query.trim()
|
||||
copy(
|
||||
isSearchActive = isSearchActive || normalized.isNotBlank(),
|
||||
showSearchResultsPanel = showSearchResultsPanel || normalized.isNotBlank(),
|
||||
searchQuery = action.query,
|
||||
activeSearchResultIndex = -1
|
||||
)
|
||||
}
|
||||
SharedPdfReaderAction.SearchOpened -> copy(
|
||||
isSearchActive = true,
|
||||
showSearchResultsPanel = true
|
||||
)
|
||||
SharedPdfReaderAction.SearchClosed -> copy(
|
||||
isSearchActive = false,
|
||||
showSearchResultsPanel = true,
|
||||
searchQuery = "",
|
||||
activeSearchResultIndex = -1
|
||||
)
|
||||
SharedPdfReaderAction.SearchResultsPanelToggled -> copy(showSearchResultsPanel = !showSearchResultsPanel)
|
||||
is SharedPdfReaderAction.SearchHighlightModeChanged -> copy(searchHighlightMode = action.mode)
|
||||
SharedPdfReaderAction.SearchHighlightModeToggled -> copy(
|
||||
searchHighlightMode = when (searchHighlightMode) {
|
||||
SearchHighlightMode.ALL -> SearchHighlightMode.FOCUSED
|
||||
SearchHighlightMode.FOCUSED -> SearchHighlightMode.ALL
|
||||
}
|
||||
)
|
||||
is SharedPdfReaderAction.GoToSearchResult -> {
|
||||
if (action.results.isEmpty()) {
|
||||
this
|
||||
} else {
|
||||
val normalizedIndex = action.resultIndex.wrapIndex(action.results.size)
|
||||
copy(
|
||||
activeSearchResultIndex = normalizedIndex,
|
||||
pageIndex = action.results[normalizedIndex].pageIndex.coerceIn(0, lastPageIndex)
|
||||
)
|
||||
}
|
||||
}
|
||||
is SharedPdfReaderAction.ToolSelected -> {
|
||||
val config = toolConfigFor(action.tool)
|
||||
copy(
|
||||
selectedTool = action.tool,
|
||||
selectedColorArgb = config.colorArgb,
|
||||
strokeWidth = config.strokeWidth,
|
||||
isTextSelectionMode = false,
|
||||
lastActivePenTool = if (action.tool.isSharedPdfPenTool) action.tool else lastActivePenTool,
|
||||
lastActiveHighlighterTool = if (action.tool.isSharedPdfHighlighterTool) {
|
||||
action.tool
|
||||
} else {
|
||||
lastActiveHighlighterTool
|
||||
}
|
||||
)
|
||||
}
|
||||
is SharedPdfReaderAction.ColorSelected -> withActiveToolColor(action.colorArgb)
|
||||
is SharedPdfReaderAction.StrokeWidthChanged -> withActiveToolStrokeWidth(action.strokeWidth.coerceAtLeast(0.0001f))
|
||||
is SharedPdfReaderAction.PenPaletteChanged -> copy(penPalette = action.colors.sanitizedSharedPdfPenPalette())
|
||||
is SharedPdfReaderAction.TextSelectionModeChanged -> {
|
||||
if (action.enabled) {
|
||||
val config = SharedPdfAnnotationDefaults.configFor(PdfInkTool.NONE)
|
||||
copy(
|
||||
isTextSelectionMode = true,
|
||||
selectedTool = PdfInkTool.NONE,
|
||||
selectedColorArgb = config.colorArgb,
|
||||
strokeWidth = config.strokeWidth
|
||||
)
|
||||
} else {
|
||||
copy(isTextSelectionMode = false)
|
||||
}
|
||||
}
|
||||
is SharedPdfReaderAction.BookmarksLoaded -> copy(bookmarks = action.bookmarks.normalizedBookmarks(lastPageIndex))
|
||||
is SharedPdfReaderAction.BookmarkToggled -> {
|
||||
val page = action.pageIndex.coerceIn(0, lastPageIndex)
|
||||
val withoutPage = bookmarks.filterNot { it.pageIndex == page }
|
||||
val nextBookmarks = if (withoutPage.size == bookmarks.size) {
|
||||
withoutPage + SharedPdfBookmark(
|
||||
pageIndex = page,
|
||||
label = action.label.ifBlank { "Page ${page + 1}" },
|
||||
createdAt = action.createdAt
|
||||
)
|
||||
} else {
|
||||
withoutPage
|
||||
}
|
||||
copy(bookmarks = nextBookmarks.normalizedBookmarks(lastPageIndex))
|
||||
}
|
||||
is SharedPdfReaderAction.AnnotationsLoaded -> copy(
|
||||
annotations = action.annotations.toList(),
|
||||
annotationUndoStack = emptyList(),
|
||||
annotationRedoStack = emptyList()
|
||||
)
|
||||
is SharedPdfReaderAction.AnnotationAdded -> copy(
|
||||
annotations = annotations + action.annotation,
|
||||
selectedAnnotationId = action.annotation.id,
|
||||
annotationUndoStack = annotationUndoStack + SharedPdfAnnotationHistoryAction.Add(
|
||||
pageIndex = action.annotation.pageIndex,
|
||||
annotation = action.annotation
|
||||
),
|
||||
annotationRedoStack = emptyList()
|
||||
)
|
||||
is SharedPdfReaderAction.AnnotationSelected -> copy(
|
||||
selectedAnnotationId = action.annotationId?.takeIf { id -> annotations.any { it.id == id } }
|
||||
)
|
||||
is SharedPdfReaderAction.AnnotationUpdated -> {
|
||||
val index = annotations.indexOfFirst { it.id == action.annotation.id }
|
||||
if (index < 0) {
|
||||
this
|
||||
} else {
|
||||
copy(
|
||||
annotations = annotations.toMutableList().also { it[index] = action.annotation },
|
||||
annotationRedoStack = emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
is SharedPdfReaderAction.AnnotationDeleted -> {
|
||||
val removed = annotations.firstOrNull { it.id == action.annotationId }
|
||||
if (removed == null) {
|
||||
this
|
||||
} else {
|
||||
copy(
|
||||
annotations = annotations.filterNot { it.id == action.annotationId },
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { it != action.annotationId },
|
||||
annotationUndoStack = annotationUndoStack + SharedPdfAnnotationHistoryAction.Remove(
|
||||
itemsByPage = mapOf(removed.pageIndex to listOf(removed))
|
||||
),
|
||||
annotationRedoStack = emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
is SharedPdfReaderAction.AnnotationsChanged -> copy(
|
||||
annotations = action.annotations.toList(),
|
||||
annotationUndoStack = emptyList(),
|
||||
annotationRedoStack = emptyList()
|
||||
)
|
||||
is SharedPdfReaderAction.UndoLastAnnotationOnPage -> {
|
||||
val index = annotations.indexOfLast { it.pageIndex == action.pageIndex }
|
||||
if (index < 0) {
|
||||
this
|
||||
} else {
|
||||
val removed = annotations[index]
|
||||
val removedId = annotations[index].id
|
||||
copy(
|
||||
annotations = annotations.toMutableList().also { it.removeAt(index) },
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { it != removedId },
|
||||
annotationUndoStack = annotationUndoStack + SharedPdfAnnotationHistoryAction.Remove(
|
||||
itemsByPage = mapOf(removed.pageIndex to listOf(removed))
|
||||
),
|
||||
annotationRedoStack = emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
SharedPdfReaderAction.UndoAnnotationEdit -> undoSharedPdfAnnotationEdit()
|
||||
SharedPdfReaderAction.RedoAnnotationEdit -> redoSharedPdfAnnotationEdit()
|
||||
is SharedPdfReaderAction.ClearPageAnnotations -> {
|
||||
val removed = annotations.filter { it.pageIndex == action.pageIndex }
|
||||
if (removed.isEmpty()) {
|
||||
this
|
||||
} else {
|
||||
val removedIds = removed.mapTo(mutableSetOf()) { it.id }
|
||||
copy(
|
||||
annotations = annotations.filterNot { it.pageIndex == action.pageIndex },
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { it !in removedIds },
|
||||
annotationUndoStack = annotationUndoStack + SharedPdfAnnotationHistoryAction.Remove(
|
||||
itemsByPage = mapOf(action.pageIndex to removed)
|
||||
),
|
||||
annotationRedoStack = emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
}.coerced(zoomSpec)
|
||||
}
|
||||
|
||||
private fun SharedPdfReaderState.toolConfigFor(tool: PdfInkTool): PdfToolConfig {
|
||||
return toolConfigs[tool] ?: SharedPdfAnnotationDefaults.configFor(tool)
|
||||
}
|
||||
|
||||
private fun SharedPdfReaderState.withActiveToolColor(colorArgb: Int): SharedPdfReaderState {
|
||||
if (!selectedTool.isSharedPdfConfigurableTool) {
|
||||
return copy(selectedColorArgb = colorArgb)
|
||||
}
|
||||
val currentConfig = toolConfigFor(selectedTool)
|
||||
return copy(
|
||||
selectedColorArgb = colorArgb,
|
||||
toolConfigs = toolConfigs + (selectedTool to currentConfig.copy(colorArgb = colorArgb))
|
||||
)
|
||||
}
|
||||
|
||||
private fun SharedPdfReaderState.withActiveToolStrokeWidth(strokeWidth: Float): SharedPdfReaderState {
|
||||
if (!selectedTool.isSharedPdfConfigurableTool) {
|
||||
return copy(strokeWidth = strokeWidth)
|
||||
}
|
||||
val currentConfig = toolConfigFor(selectedTool)
|
||||
return copy(
|
||||
strokeWidth = strokeWidth,
|
||||
toolConfigs = toolConfigs + (selectedTool to currentConfig.copy(strokeWidth = strokeWidth))
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<Int>.sanitizedSharedPdfPenPalette(): List<Int> {
|
||||
val defaults = SharedPdfAnnotationDefaults.penPalette
|
||||
val normalized = filter { it != 0 }.take(defaults.size)
|
||||
val filled = if (normalized.isEmpty()) {
|
||||
defaults
|
||||
} else {
|
||||
normalized + defaults.drop(normalized.size)
|
||||
}
|
||||
return filled.take(defaults.size)
|
||||
}
|
||||
|
||||
private val PdfInkTool.isSharedPdfPenTool: Boolean
|
||||
get() = this == PdfInkTool.FOUNTAIN_PEN || this == PdfInkTool.PEN || this == PdfInkTool.PENCIL
|
||||
|
||||
private val PdfInkTool.isSharedPdfHighlighterTool: Boolean
|
||||
get() = this == PdfInkTool.HIGHLIGHTER || this == PdfInkTool.HIGHLIGHTER_ROUND
|
||||
|
||||
private val PdfInkTool.isSharedPdfConfigurableTool: Boolean
|
||||
get() = this != PdfInkTool.NONE
|
||||
|
||||
private fun SharedPdfReaderState.undoSharedPdfAnnotationEdit(): SharedPdfReaderState {
|
||||
val action = annotationUndoStack.lastOrNull() ?: return this
|
||||
val nextUndoStack = annotationUndoStack.dropLast(1)
|
||||
return when (action) {
|
||||
is SharedPdfAnnotationHistoryAction.Add -> copy(
|
||||
annotations = annotations.filterNot { it.id == action.annotation.id },
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { it != action.annotation.id },
|
||||
annotationUndoStack = nextUndoStack,
|
||||
annotationRedoStack = annotationRedoStack + action
|
||||
)
|
||||
|
||||
is SharedPdfAnnotationHistoryAction.Remove -> copy(
|
||||
annotations = annotations + action.itemsByPage.values.flatten(),
|
||||
annotationUndoStack = nextUndoStack,
|
||||
annotationRedoStack = annotationRedoStack + action
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun SharedPdfReaderState.redoSharedPdfAnnotationEdit(): SharedPdfReaderState {
|
||||
val action = annotationRedoStack.lastOrNull() ?: return this
|
||||
val nextRedoStack = annotationRedoStack.dropLast(1)
|
||||
return when (action) {
|
||||
is SharedPdfAnnotationHistoryAction.Add -> copy(
|
||||
annotations = annotations + action.annotation,
|
||||
selectedAnnotationId = action.annotation.id,
|
||||
annotationUndoStack = annotationUndoStack + action,
|
||||
annotationRedoStack = nextRedoStack
|
||||
)
|
||||
|
||||
is SharedPdfAnnotationHistoryAction.Remove -> {
|
||||
val removedIds = action.itemsByPage.values.flatten().mapTo(mutableSetOf()) { it.id }
|
||||
copy(
|
||||
annotations = annotations.filterNot { it.id in removedIds },
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { it !in removedIds },
|
||||
annotationUndoStack = annotationUndoStack + action,
|
||||
annotationRedoStack = nextRedoStack
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object SharedPdfSearchEngine {
|
||||
fun search(
|
||||
pageTexts: List<String>,
|
||||
query: String,
|
||||
previewRadiusBefore: Int = 70,
|
||||
previewRadiusAfter: Int = 100
|
||||
): List<SharedPdfSearchResult> {
|
||||
val normalized = query.trim()
|
||||
if (normalized.isBlank()) return emptyList()
|
||||
return pageTexts.flatMapIndexed { pageIndex, text ->
|
||||
val matches = mutableListOf<SharedPdfSearchResult>()
|
||||
var startIndex = 0
|
||||
while (startIndex < text.length) {
|
||||
val matchIndex = text.indexOf(normalized, startIndex, ignoreCase = true)
|
||||
if (matchIndex < 0) break
|
||||
matches += SharedPdfSearchResult(
|
||||
pageIndex = pageIndex,
|
||||
preview = text.previewAround(
|
||||
index = matchIndex,
|
||||
queryLength = normalized.length,
|
||||
before = previewRadiusBefore,
|
||||
after = previewRadiusAfter
|
||||
),
|
||||
matchIndex = matchIndex,
|
||||
matchLength = normalized.length
|
||||
)
|
||||
startIndex = matchIndex + normalized.length.coerceAtLeast(1)
|
||||
}
|
||||
matches
|
||||
}
|
||||
}
|
||||
|
||||
fun highlightsForPage(
|
||||
results: List<SharedPdfSearchResult>,
|
||||
pageIndex: Int,
|
||||
activeResultIndex: Int,
|
||||
mode: SearchHighlightMode
|
||||
): List<SharedPdfSearchResult> {
|
||||
return when (mode) {
|
||||
SearchHighlightMode.ALL -> results.filter { it.pageIndex == pageIndex }
|
||||
SearchHighlightMode.FOCUSED -> {
|
||||
val active = results.getOrNull(activeResultIndex)
|
||||
if (active?.pageIndex == pageIndex) listOf(active) else emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SharedPdfSearchIndex(
|
||||
val pageCount: Int = 0
|
||||
) {
|
||||
private val pageTexts = LinkedHashMap<Int, String>()
|
||||
private val tokenPages = LinkedHashMap<String, MutableSet<Int>>()
|
||||
|
||||
val indexedPageCount: Int
|
||||
get() = pageTexts.size
|
||||
|
||||
fun hasPage(pageIndex: Int): Boolean = pageTexts.containsKey(pageIndex)
|
||||
|
||||
fun pageText(pageIndex: Int): String? = pageTexts[pageIndex]
|
||||
|
||||
fun indexedPages(): List<SharedPdfIndexedPage> {
|
||||
return pageTexts.entries
|
||||
.sortedBy { it.key }
|
||||
.map { SharedPdfIndexedPage(pageIndex = it.key, text = it.value) }
|
||||
}
|
||||
|
||||
fun putPage(pageIndex: Int, text: String) {
|
||||
if (pageCount > 0 && pageIndex !in 0 until pageCount) return
|
||||
removePageTokens(pageIndex)
|
||||
pageTexts[pageIndex] = text
|
||||
text.searchTokens().forEach { token ->
|
||||
tokenPages.getOrPut(token) { linkedSetOf() } += pageIndex
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
pageTexts.clear()
|
||||
tokenPages.clear()
|
||||
}
|
||||
|
||||
fun search(
|
||||
query: String,
|
||||
previewRadiusBefore: Int = 70,
|
||||
previewRadiusAfter: Int = 100
|
||||
): List<SharedPdfSearchResult> {
|
||||
val normalized = query.trim()
|
||||
if (normalized.isBlank()) return emptyList()
|
||||
val matcher = SharedPdfPhraseMatcher(normalized)
|
||||
val candidates = candidatePages(matcher.tokens)
|
||||
return candidates.flatMap { pageIndex ->
|
||||
val text = pageTexts[pageIndex].orEmpty()
|
||||
matcher.findAll(text).map { match ->
|
||||
SharedPdfSearchResult(
|
||||
pageIndex = pageIndex,
|
||||
preview = text.previewAround(
|
||||
index = match.startIndex,
|
||||
queryLength = match.length,
|
||||
before = previewRadiusBefore,
|
||||
after = previewRadiusAfter
|
||||
),
|
||||
matchIndex = match.startIndex,
|
||||
matchLength = match.length
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun candidatePages(tokens: List<String>): List<Int> {
|
||||
if (tokens.isEmpty()) return pageTexts.keys.sorted()
|
||||
val candidateSets = tokens.map { token ->
|
||||
tokenPages.asSequence()
|
||||
.filter { (indexedToken, _) -> indexedToken.startsWith(token) }
|
||||
.flatMap { (_, pages) -> pages.asSequence() }
|
||||
.toSet()
|
||||
}
|
||||
if (candidateSets.any { it.isEmpty() }) return emptyList()
|
||||
return candidateSets
|
||||
.drop(1)
|
||||
.fold(candidateSets.first()) { acc, pages -> acc.intersect(pages) }
|
||||
.sorted()
|
||||
}
|
||||
|
||||
private fun removePageTokens(pageIndex: Int) {
|
||||
if (!pageTexts.containsKey(pageIndex)) return
|
||||
val emptyTokens = mutableListOf<String>()
|
||||
tokenPages.forEach { (token, pages) ->
|
||||
pages.remove(pageIndex)
|
||||
if (pages.isEmpty()) emptyTokens += token
|
||||
}
|
||||
emptyTokens.forEach(tokenPages::remove)
|
||||
}
|
||||
}
|
||||
|
||||
data class SharedPdfIndexedPage(
|
||||
val pageIndex: Int,
|
||||
val text: String
|
||||
)
|
||||
|
||||
private data class SharedPdfPhraseMatch(
|
||||
val startIndex: Int,
|
||||
val length: Int
|
||||
)
|
||||
|
||||
private class SharedPdfPhraseMatcher(query: String) {
|
||||
val tokens: List<String> = query.searchTokens()
|
||||
private val regex = query.toSearchPhraseRegex()
|
||||
private val literal = query.takeIf { regex == null }
|
||||
|
||||
fun findAll(text: String): List<SharedPdfPhraseMatch> {
|
||||
return if (regex != null) {
|
||||
regex.findAll(text).map { match ->
|
||||
SharedPdfPhraseMatch(
|
||||
startIndex = match.range.first,
|
||||
length = match.range.last - match.range.first + 1
|
||||
)
|
||||
}.toList()
|
||||
} else {
|
||||
val needle = literal.orEmpty()
|
||||
val matches = mutableListOf<SharedPdfPhraseMatch>()
|
||||
var startIndex = 0
|
||||
while (startIndex < text.length) {
|
||||
val matchIndex = text.indexOf(needle, startIndex, ignoreCase = true)
|
||||
if (matchIndex < 0) break
|
||||
matches += SharedPdfPhraseMatch(matchIndex, needle.length)
|
||||
startIndex = matchIndex + needle.length.coerceAtLeast(1)
|
||||
}
|
||||
matches
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toSearchPhraseRegex(): Regex? {
|
||||
val tokens = trim().split(Regex("\\s+")).filter { it.isNotBlank() }
|
||||
if (tokens.size <= 1) return null
|
||||
val prefix = if (all { it.code < 128 }) "\\b" else ""
|
||||
return Regex(prefix + tokens.joinToString("\\s+") { Regex.escape(it) }, RegexOption.IGNORE_CASE)
|
||||
}
|
||||
|
||||
private fun Int.wrapIndex(size: Int): Int {
|
||||
if (size <= 0) return -1
|
||||
return when {
|
||||
this < 0 -> size - 1
|
||||
this >= size -> 0
|
||||
else -> this
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<SharedPdfBookmark>.normalizedBookmarks(lastPageIndex: Int): List<SharedPdfBookmark> {
|
||||
return asSequence()
|
||||
.filter { it.pageIndex in 0..lastPageIndex }
|
||||
.distinctBy { it.pageIndex }
|
||||
.sortedBy { it.pageIndex }
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun String.previewAround(
|
||||
index: Int,
|
||||
queryLength: Int,
|
||||
before: Int,
|
||||
after: Int
|
||||
): String {
|
||||
val start = (index - before).coerceAtLeast(0)
|
||||
val end = (index + queryLength + after).coerceAtMost(length)
|
||||
val prefix = if (start > 0) "..." else ""
|
||||
val suffix = if (end < length) "..." else ""
|
||||
return prefix + substring(start, end).replace(Regex("\\s+"), " ").trim() + suffix
|
||||
}
|
||||
|
||||
private fun String.searchTokens(): List<String> {
|
||||
val tokens = mutableListOf<String>()
|
||||
val current = StringBuilder()
|
||||
forEach { char ->
|
||||
if (char.isLetterOrDigit() || char == '_') {
|
||||
current.append(char.lowercaseChar())
|
||||
} else if (current.isNotEmpty()) {
|
||||
tokens += current.toString()
|
||||
current.setLength(0)
|
||||
}
|
||||
}
|
||||
if (current.isNotEmpty()) tokens += current.toString()
|
||||
return tokens.distinct()
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
package org.dueattendant149.bookreader.shared.pdf
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
data class PdfNormalizedPoint(
|
||||
val x: Float,
|
||||
val y: Float
|
||||
)
|
||||
|
||||
data class PdfTextCharBounds(
|
||||
val index: Int,
|
||||
val left: Float,
|
||||
val top: Float,
|
||||
val right: Float,
|
||||
val bottom: Float
|
||||
) {
|
||||
val hasBounds: Boolean
|
||||
get() = right > left && bottom > top
|
||||
}
|
||||
|
||||
object PdfSelectionGeometry {
|
||||
private const val DefaultMergedLineTolerance = 0.006f
|
||||
private const val DefaultCharLineTolerance = 0.012f
|
||||
private const val MinLineTolerance = 0.002f
|
||||
|
||||
fun normalizedPoint(
|
||||
pointX: Float,
|
||||
pointY: Float,
|
||||
viewportWidth: Int,
|
||||
viewportHeight: Int
|
||||
): PdfNormalizedPoint? {
|
||||
if (viewportWidth <= 0 || viewportHeight <= 0) return null
|
||||
return PdfNormalizedPoint(
|
||||
x = (pointX / viewportWidth).coerceIn(0f, 1f),
|
||||
y = (pointY / viewportHeight).coerceIn(0f, 1f)
|
||||
)
|
||||
}
|
||||
|
||||
fun mergeBoundsByLine(
|
||||
bounds: List<PdfPageBounds>,
|
||||
lineTolerance: Float = DefaultMergedLineTolerance
|
||||
): List<PdfPageBounds> {
|
||||
if (bounds.isEmpty()) return emptyList()
|
||||
val lines = mutableListOf<MutableList<PdfPageBounds>>()
|
||||
bounds.sortedWith(compareBy<PdfPageBounds> { it.top }.thenBy { it.left }).forEach { boundsForChar ->
|
||||
val line = lines.firstOrNull { existing ->
|
||||
existing.any { it.isSameVisualLineAs(boundsForChar, lineTolerance) }
|
||||
}
|
||||
if (line == null) {
|
||||
lines += mutableListOf(boundsForChar)
|
||||
} else {
|
||||
line += boundsForChar
|
||||
}
|
||||
}
|
||||
return lines.map { it.toMergedBounds() }
|
||||
}
|
||||
|
||||
fun lineBoundsForChars(
|
||||
chars: List<PdfTextCharBounds>,
|
||||
lineTolerance: Float = DefaultCharLineTolerance
|
||||
): List<PdfPageBounds> {
|
||||
return mergeBoundsByLine(
|
||||
bounds = chars.groupByLine(lineTolerance).map { it.toCharLineBounds() }
|
||||
)
|
||||
}
|
||||
|
||||
fun nearestCharOnLine(
|
||||
chars: List<PdfTextCharBounds>,
|
||||
point: PdfNormalizedPoint,
|
||||
lineTolerance: Float = DefaultCharLineTolerance
|
||||
): PdfTextCharBounds? {
|
||||
val lines = chars.groupByLine(lineTolerance)
|
||||
val matchingLines = lines.filter { line ->
|
||||
val top = line.minOf { it.top }
|
||||
val bottom = line.maxOf { it.bottom }
|
||||
val averageHeight = line.map { it.bottom - it.top }.average().toFloat()
|
||||
val verticalPadding = maxOf(averageHeight * 0.45f, MinLineTolerance)
|
||||
point.y in (top - verticalPadding)..(bottom + verticalPadding)
|
||||
}
|
||||
val line = matchingLines.minWithOrNull(
|
||||
compareBy<List<PdfTextCharBounds>>(
|
||||
{ lineVerticalDistance(point.y, it) },
|
||||
{ lineHorizontalDistance(point.x, it) }
|
||||
)
|
||||
) ?: return null
|
||||
|
||||
return line.minByOrNull { char ->
|
||||
horizontalDistance(point.x, char)
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<PdfTextCharBounds>.groupByLine(lineTolerance: Float): List<List<PdfTextCharBounds>> {
|
||||
val lines = mutableListOf<MutableList<PdfTextCharBounds>>()
|
||||
filter { it.hasBounds }
|
||||
.sortedWith(compareBy<PdfTextCharBounds> { it.top }.thenBy { it.left })
|
||||
.forEach { char ->
|
||||
val line = lines.firstOrNull { existing ->
|
||||
val averageHeight = existing.map { it.bottom - it.top }.average().toFloat()
|
||||
val charHeight = char.bottom - char.top
|
||||
val dynamicTolerance = maxOf(minOf(averageHeight, charHeight) * 0.55f, MinLineTolerance)
|
||||
abs(existing.averageVerticalMidpoint() - char.verticalMidpoint()) <= minOf(lineTolerance, dynamicTolerance)
|
||||
}
|
||||
if (line == null) {
|
||||
lines += mutableListOf(char)
|
||||
} else {
|
||||
line += char
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
private fun List<PdfTextCharBounds>.toCharLineBounds(): PdfPageBounds {
|
||||
return PdfPageBounds(
|
||||
left = minOf { it.left }.coerceIn(0f, 1f),
|
||||
top = minOf { it.top }.coerceIn(0f, 1f),
|
||||
right = maxOf { it.right }.coerceIn(0f, 1f),
|
||||
bottom = maxOf { it.bottom }.coerceIn(0f, 1f)
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<PdfPageBounds>.toMergedBounds(): PdfPageBounds {
|
||||
return PdfPageBounds(
|
||||
left = minOf { it.left }.coerceIn(0f, 1f),
|
||||
top = minOf { it.top }.coerceIn(0f, 1f),
|
||||
right = maxOf { it.right }.coerceIn(0f, 1f),
|
||||
bottom = maxOf { it.bottom }.coerceIn(0f, 1f)
|
||||
)
|
||||
}
|
||||
|
||||
private fun PdfTextCharBounds.verticalMidpoint(): Float = (top + bottom) / 2f
|
||||
|
||||
private fun PdfPageBounds.isSameVisualLineAs(other: PdfPageBounds, lineTolerance: Float): Boolean {
|
||||
val overlap = minOf(bottom, other.bottom) - maxOf(top, other.top)
|
||||
val minHeight = minOf(bottom - top, other.bottom - other.top)
|
||||
if (overlap > 0f && overlap >= minHeight * 0.45f) return true
|
||||
|
||||
val dynamicTolerance = maxOf(minHeight * 0.35f, MinLineTolerance)
|
||||
return abs(verticalMidpoint() - other.verticalMidpoint()) <= minOf(lineTolerance, dynamicTolerance)
|
||||
}
|
||||
|
||||
private fun PdfPageBounds.verticalMidpoint(): Float = (top + bottom) / 2f
|
||||
|
||||
private fun List<PdfTextCharBounds>.averageVerticalMidpoint(): Float {
|
||||
return map { it.verticalMidpoint() }.average().toFloat()
|
||||
}
|
||||
|
||||
private fun lineVerticalDistance(pointY: Float, line: List<PdfTextCharBounds>): Float {
|
||||
val top = line.minOf { it.top }
|
||||
val bottom = line.maxOf { it.bottom }
|
||||
return when {
|
||||
pointY < top -> top - pointY
|
||||
pointY > bottom -> pointY - bottom
|
||||
else -> 0f
|
||||
}
|
||||
}
|
||||
|
||||
private fun lineHorizontalDistance(pointX: Float, line: List<PdfTextCharBounds>): Float {
|
||||
val left = line.minOf { it.left }
|
||||
val right = line.maxOf { it.right }
|
||||
return when {
|
||||
pointX < left -> left - pointX
|
||||
pointX > right -> pointX - right
|
||||
else -> 0f
|
||||
}
|
||||
}
|
||||
|
||||
private fun horizontalDistance(pointX: Float, char: PdfTextCharBounds): Float {
|
||||
return when {
|
||||
pointX < char.left -> char.left - pointX
|
||||
pointX > char.right -> pointX - char.right
|
||||
else -> 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
package org.dueattendant149.bookreader.shared.pdf
|
||||
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderPageSpreadMode
|
||||
import org.dueattendant149.bookreader.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 visiblePageIndicesForDisplay(
|
||||
pageIndex: Int,
|
||||
pageCount: Int,
|
||||
settings: ReaderSettings
|
||||
): List<Int> {
|
||||
val indices = visiblePageIndices(pageIndex, pageCount, settings)
|
||||
return if (settings.rightToLeftPagination) indices.asReversed() else indices
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
package org.dueattendant149.bookreader.shared.pdf
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.floor
|
||||
|
||||
data class PdfVisiblePageLayout(
|
||||
val pageIndex: Int,
|
||||
val top: Float,
|
||||
val bottom: Float
|
||||
) {
|
||||
val visibleHeight: Float
|
||||
get() = (bottom - top).coerceAtLeast(0f)
|
||||
}
|
||||
|
||||
fun mostVisiblePdfPageIndex(
|
||||
visiblePages: List<PdfVisiblePageLayout>,
|
||||
viewportTop: Float,
|
||||
viewportBottom: Float,
|
||||
fallbackPageIndex: Int
|
||||
): Int {
|
||||
return visiblePages
|
||||
.filter { it.visibleHeight > 0f }
|
||||
.map { page ->
|
||||
val top = maxOf(page.top, viewportTop)
|
||||
val bottom = minOf(page.bottom, viewportBottom)
|
||||
page to (bottom - top).coerceAtLeast(0f)
|
||||
}
|
||||
.maxByOrNull { it.second }
|
||||
?.takeIf { it.second > 0f }
|
||||
?.first
|
||||
?.pageIndex
|
||||
?: fallbackPageIndex
|
||||
}
|
||||
|
||||
fun pdfVerticalPageGapDp(
|
||||
isPageGapVisible: Boolean,
|
||||
defaultGap: Dp
|
||||
): Dp = if (isPageGapVisible) defaultGap else 0.dp
|
||||
|
||||
data class PdfVerticalPagePlacement(
|
||||
val pageIndex: Int,
|
||||
val topPx: Int,
|
||||
val widthPx: Int,
|
||||
val heightPx: Int
|
||||
) {
|
||||
val bottomPx: Int
|
||||
get() = topPx + heightPx
|
||||
}
|
||||
|
||||
data class PdfVerticalPageLayoutResult(
|
||||
val pages: List<PdfVerticalPagePlacement>,
|
||||
val totalHeightPx: Int
|
||||
)
|
||||
|
||||
fun calculatePdfVerticalPageLayoutPx(
|
||||
pageAspectRatios: List<Float>,
|
||||
viewportWidthPx: Int,
|
||||
viewportHeightPx: Int,
|
||||
pageGapPx: Int
|
||||
): PdfVerticalPageLayoutResult {
|
||||
val safeWidthPx = viewportWidthPx.coerceAtLeast(0)
|
||||
if (pageAspectRatios.isEmpty() || safeWidthPx == 0) {
|
||||
return PdfVerticalPageLayoutResult(emptyList(), 0)
|
||||
}
|
||||
|
||||
val safeGapPx = pageGapPx.coerceAtLeast(0)
|
||||
val safeHeightPx = viewportHeightPx.coerceAtLeast(0)
|
||||
|
||||
fun pageHeightPx(ratio: Float): Int {
|
||||
val safeRatio = if (ratio <= 0f) 1f else ratio
|
||||
return floor(safeWidthPx.toDouble() / safeRatio.toDouble())
|
||||
.toInt()
|
||||
.coerceAtLeast(1)
|
||||
}
|
||||
|
||||
var currentTopPx = 0
|
||||
if (pageAspectRatios.size == 1) {
|
||||
val singlePageHeightPx = pageHeightPx(pageAspectRatios[0])
|
||||
if (singlePageHeightPx < safeHeightPx) {
|
||||
currentTopPx = (safeHeightPx - singlePageHeightPx) / 2
|
||||
}
|
||||
}
|
||||
|
||||
val pages = pageAspectRatios.mapIndexed { index, ratio ->
|
||||
val heightPx = pageHeightPx(ratio)
|
||||
val placement = PdfVerticalPagePlacement(
|
||||
pageIndex = index,
|
||||
topPx = currentTopPx,
|
||||
widthPx = safeWidthPx,
|
||||
heightPx = heightPx
|
||||
)
|
||||
currentTopPx += heightPx
|
||||
if (index < pageAspectRatios.lastIndex) {
|
||||
currentTopPx += safeGapPx
|
||||
}
|
||||
placement
|
||||
}
|
||||
|
||||
return PdfVerticalPageLayoutResult(
|
||||
pages = pages,
|
||||
totalHeightPx = pages.lastOrNull()?.bottomPx ?: 0
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
package org.dueattendant149.bookreader.shared.pdf
|
||||
|
||||
/**
|
||||
* Platform-neutral surface for Pdfium functions that are not exposed by the
|
||||
* higher-level document/page API. Platform implementations can back this with
|
||||
* Android JNI, Windows Pdfium binaries, or another native binding.
|
||||
*/
|
||||
interface PdfiumBridge {
|
||||
fun getFontSize(textPagePtr: Long, index: Int): Double
|
||||
fun getFontWeight(textPagePtr: Long, index: Int): Int
|
||||
|
||||
fun getPageFontSizes(textPagePtr: Long, count: Int): FloatArray?
|
||||
fun getPageFontWeights(textPagePtr: Long, count: Int): IntArray?
|
||||
fun getPageFontFlags(textPagePtr: Long, count: Int): IntArray?
|
||||
fun getPageCharBoxes(textPagePtr: Long, count: Int): FloatArray?
|
||||
|
||||
fun getAnnotCount(pagePtr: Long): Int
|
||||
fun getAnnotSubtype(pagePtr: Long, index: Int): Int
|
||||
fun getAnnotRect(pagePtr: Long, index: Int): FloatArray?
|
||||
fun getAnnotString(pagePtr: Long, index: Int, key: String): String?
|
||||
|
||||
fun getPageObjectCount(pagePtr: Long): Int
|
||||
fun getPageObjectType(pagePtr: Long, index: Int): Int
|
||||
fun getPageObjectBoundingBox(pagePtr: Long, index: Int, outRect: FloatArray): Boolean
|
||||
fun extractImagePixels(pagePtr: Long, index: Int, dimens: IntArray): IntArray?
|
||||
|
||||
fun performClick(pagePtr: Long, x: Double, y: Double): Boolean
|
||||
fun getLinkInfoAtPoint(docPtr: Long, pagePtr: Long, x: Double, y: Double): String?
|
||||
|
||||
fun getAnnotSubtypeAtPoint(pagePtr: Long, x: Double, y: Double): Int
|
||||
fun getAnnotRectAtPoint(pagePtr: Long, x: Double, y: Double): FloatArray?
|
||||
fun checkActionSupport(): Boolean
|
||||
}
|
||||
|
||||
object PdfiumAnnotationSubtype {
|
||||
const val TEXT = 1
|
||||
const val LINK = 2
|
||||
const val HIGHLIGHT = 8
|
||||
const val INK = 12
|
||||
const val WIDGET = 19
|
||||
}
|
||||
|
||||
object NoOpPdfiumBridge : PdfiumBridge {
|
||||
override fun getFontSize(textPagePtr: Long, index: Int): Double = 0.0
|
||||
override fun getFontWeight(textPagePtr: Long, index: Int): Int = 0
|
||||
override fun getPageFontSizes(textPagePtr: Long, count: Int): FloatArray? = null
|
||||
override fun getPageFontWeights(textPagePtr: Long, count: Int): IntArray? = null
|
||||
override fun getPageFontFlags(textPagePtr: Long, count: Int): IntArray? = null
|
||||
override fun getPageCharBoxes(textPagePtr: Long, count: Int): FloatArray? = null
|
||||
override fun getAnnotCount(pagePtr: Long): Int = 0
|
||||
override fun getAnnotSubtype(pagePtr: Long, index: Int): Int = 0
|
||||
override fun getAnnotRect(pagePtr: Long, index: Int): FloatArray? = null
|
||||
override fun getAnnotString(pagePtr: Long, index: Int, key: String): String? = null
|
||||
override fun getPageObjectCount(pagePtr: Long): Int = 0
|
||||
override fun getPageObjectType(pagePtr: Long, index: Int): Int = 0
|
||||
override fun getPageObjectBoundingBox(pagePtr: Long, index: Int, outRect: FloatArray): Boolean = false
|
||||
override fun extractImagePixels(pagePtr: Long, index: Int, dimens: IntArray): IntArray? = null
|
||||
override fun performClick(pagePtr: Long, x: Double, y: Double): Boolean = false
|
||||
override fun getLinkInfoAtPoint(docPtr: Long, pagePtr: Long, x: Double, y: Double): String? = null
|
||||
override fun getAnnotSubtypeAtPoint(pagePtr: Long, x: Double, y: Double): Int = -1
|
||||
override fun getAnnotRectAtPoint(pagePtr: Long, x: Double, y: Double): FloatArray? = null
|
||||
override fun checkActionSupport(): Boolean = false
|
||||
}
|
||||
|
|
@ -0,0 +1,319 @@
|
|||
package org.dueattendant149.bookreader.shared.pdf
|
||||
|
||||
import kotlin.math.sqrt
|
||||
|
||||
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_SHARED_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_SHARED_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()
|
||||
}
|
||||
|
|
@ -0,0 +1,557 @@
|
|||
package org.dueattendant149.bookreader.shared.pdf
|
||||
|
||||
import org.dueattendant149.bookreader.shared.localFolderSyncSha256ShortHex
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
|
||||
object SharedPdfAnnotationSidecarCodec {
|
||||
const val KEY_PDF_ANNOTATIONS = "pdfAnnotations"
|
||||
const val KEY_PDF_ANNOTATION_DELETIONS = "pdfAnnotationDeletions"
|
||||
const val KEY_LEGACY_INK = "ink"
|
||||
const val KEY_LEGACY_TEXT_BOXES = "textBoxes"
|
||||
const val KEY_LEGACY_HIGHLIGHTS = "highlights"
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
prettyPrint = true
|
||||
}
|
||||
|
||||
fun encodeAnnotationsElement(annotations: List<SharedPdfAnnotation>): JsonElement {
|
||||
return json.parseToJsonElement(SharedPdfAnnotationSerializer.encode(annotations))
|
||||
}
|
||||
|
||||
fun decodeAnnotationsElement(element: JsonElement): List<SharedPdfAnnotation> {
|
||||
return SharedPdfAnnotationSerializer.decode(json.encodeToString(JsonElement.serializer(), element))
|
||||
}
|
||||
|
||||
fun annotationsFromData(data: JsonObject): List<SharedPdfAnnotation> {
|
||||
val deletedIds = annotationDeletionsFromData(data).keys
|
||||
data[KEY_PDF_ANNOTATIONS]?.let { return decodeAnnotationsElement(it).filterNot { annotation -> annotation.id in deletedIds } }
|
||||
|
||||
data[KEY_LEGACY_INK]?.let { ink ->
|
||||
val decoded = decodeAnnotationsElement(ink)
|
||||
if (decoded.isNotEmpty() || ink.looksLikeSharedAnnotationStore()) {
|
||||
return decoded.filterNot { annotation -> annotation.id in deletedIds }
|
||||
}
|
||||
}
|
||||
|
||||
return legacyAndroidAnnotationsFromData(data).filterNot { annotation -> annotation.id in deletedIds }
|
||||
}
|
||||
|
||||
fun withCanonicalAnnotations(data: JsonObject): JsonObject {
|
||||
if (data[KEY_PDF_ANNOTATIONS] != null) return data
|
||||
val annotations = annotationsFromData(data)
|
||||
if (annotations.isEmpty() && !data.hasLegacyAndroidAnnotationPayload()) return data
|
||||
return JsonObject(data + (KEY_PDF_ANNOTATIONS to encodeAnnotationsElement(annotations)))
|
||||
}
|
||||
|
||||
fun canonicalizeDataJson(rawDataJson: String): String {
|
||||
val data = parseObjectOrNull(rawDataJson) ?: return rawDataJson
|
||||
return json.encodeToString(JsonElement.serializer(), withCanonicalAnnotations(data))
|
||||
}
|
||||
|
||||
fun mergeAnnotationDataJson(
|
||||
localDataJson: String,
|
||||
remoteDataJson: String,
|
||||
preferRemoteOnConflict: Boolean
|
||||
): String {
|
||||
val localData = parseObjectOrNull(localDataJson)?.sidecarDataObject() ?: JsonObject(emptyMap())
|
||||
val remoteData = parseObjectOrNull(remoteDataJson)?.sidecarDataObject() ?: JsonObject(emptyMap())
|
||||
val localCanonical = withCanonicalAnnotations(localData)
|
||||
val remoteCanonical = withCanonicalAnnotations(remoteData)
|
||||
val localAnnotations = annotationsFromData(localCanonical)
|
||||
val remoteAnnotations = annotationsFromData(remoteCanonical)
|
||||
val mergedDeletions = mergeAnnotationDeletions(
|
||||
annotationDeletionsFromData(localCanonical),
|
||||
annotationDeletionsFromData(remoteCanonical)
|
||||
)
|
||||
val mergedById = linkedMapOf<String, SharedPdfAnnotation>()
|
||||
val first = if (preferRemoteOnConflict) localAnnotations else remoteAnnotations
|
||||
val second = if (preferRemoteOnConflict) remoteAnnotations else localAnnotations
|
||||
first.forEach { annotation ->
|
||||
if (annotation.id !in mergedDeletions) mergedById[annotation.id] = annotation
|
||||
}
|
||||
second.forEach { annotation ->
|
||||
if (annotation.id !in mergedDeletions) mergedById[annotation.id] = annotation
|
||||
}
|
||||
val base = (if (preferRemoteOnConflict) remoteCanonical else localCanonical).toMutableMap()
|
||||
base[KEY_PDF_ANNOTATIONS] = encodeAnnotationsElement(mergedById.values.toList().sortedForSync())
|
||||
if (mergedDeletions.isNotEmpty()) {
|
||||
base[KEY_PDF_ANNOTATION_DELETIONS] = encodeAnnotationDeletionsElement(mergedDeletions)
|
||||
} else {
|
||||
base.remove(KEY_PDF_ANNOTATION_DELETIONS)
|
||||
}
|
||||
return json.encodeToString(JsonElement.serializer(), JsonObject(base))
|
||||
}
|
||||
|
||||
fun annotationCountFromDataJson(rawDataJson: String): Int {
|
||||
val data = parseObjectOrNull(rawDataJson)?.sidecarDataObject() ?: return 0
|
||||
return annotationsFromData(withCanonicalAnnotations(data)).size
|
||||
}
|
||||
|
||||
fun annotationDeletionsFromData(data: JsonObject): Map<String, Long> {
|
||||
return data[KEY_PDF_ANNOTATION_DELETIONS].parseAnnotationDeletions()
|
||||
}
|
||||
|
||||
fun annotationDeletionsFromJson(rawJson: String): Map<String, Long> {
|
||||
val element = runCatching { json.parseToJsonElement(rawJson) }.getOrNull() ?: return emptyMap()
|
||||
return when (element) {
|
||||
is JsonObject -> {
|
||||
val data = element.sidecarDataObject()
|
||||
annotationDeletionsFromData(data).ifEmpty { element.parseAnnotationDeletions() }
|
||||
}
|
||||
else -> element.parseAnnotationDeletions()
|
||||
}
|
||||
}
|
||||
|
||||
fun annotationDeletionsJson(deletions: Map<String, Long>): String {
|
||||
return json.encodeToString(JsonElement.serializer(), encodeAnnotationDeletionsElement(deletions))
|
||||
}
|
||||
|
||||
fun encodeAnnotationDeletionsElement(deletions: Map<String, Long>): JsonElement {
|
||||
return JsonArray(
|
||||
deletions
|
||||
.filterKeys { it.isNotBlank() }
|
||||
.toSortedMap()
|
||||
.map { (id, deletedAt) ->
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"deletedAt" to JsonPrimitive(deletedAt)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun legacyAndroidDataFromAnnotations(
|
||||
annotations: List<SharedPdfAnnotation>,
|
||||
existingData: JsonObject = JsonObject(emptyMap())
|
||||
): JsonObject {
|
||||
val next = existingData.toMutableMap()
|
||||
val replaceLegacyFromCanonical = existingData[KEY_PDF_ANNOTATIONS] != null
|
||||
if (annotations.isEmpty() && !replaceLegacyFromCanonical) return existingData
|
||||
|
||||
if (replaceLegacyFromCanonical || !existingData[KEY_LEGACY_INK].isLegacyAndroidInkArray()) {
|
||||
next[KEY_LEGACY_INK] = annotations.toLegacyAndroidInkArray()
|
||||
}
|
||||
if (replaceLegacyFromCanonical || !existingData[KEY_LEGACY_TEXT_BOXES].isJsonArray()) {
|
||||
next[KEY_LEGACY_TEXT_BOXES] = annotations.toLegacyAndroidTextBoxArray()
|
||||
}
|
||||
if (replaceLegacyFromCanonical || !existingData[KEY_LEGACY_HIGHLIGHTS].isJsonArray()) {
|
||||
next[KEY_LEGACY_HIGHLIGHTS] = annotations.toLegacyAndroidHighlightArray()
|
||||
}
|
||||
return JsonObject(next)
|
||||
}
|
||||
|
||||
fun legacyAndroidDataJsonFromCanonical(rawDataJson: String): String {
|
||||
val data = parseObjectOrNull(rawDataJson) ?: return rawDataJson
|
||||
val annotations = annotationsFromData(data)
|
||||
if (annotations.isEmpty() && data[KEY_PDF_ANNOTATIONS] == null) return rawDataJson
|
||||
return json.encodeToString(
|
||||
JsonElement.serializer(),
|
||||
legacyAndroidDataFromAnnotations(annotations, data)
|
||||
)
|
||||
}
|
||||
|
||||
private fun legacyAndroidAnnotationsFromData(data: JsonObject): List<SharedPdfAnnotation> {
|
||||
return buildList {
|
||||
addAll(data[KEY_LEGACY_INK].parseLegacyAndroidInk())
|
||||
addAll(data[KEY_LEGACY_TEXT_BOXES].parseLegacyAndroidTextBoxes())
|
||||
addAll(data[KEY_LEGACY_HIGHLIGHTS].parseLegacyAndroidHighlights())
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.parseLegacyAndroidInk(): List<SharedPdfAnnotation> {
|
||||
val array = this?.jsonArrayOrNull() ?: return emptyList()
|
||||
if (!this.isLegacyAndroidInkArray()) return emptyList()
|
||||
return array.mapNotNull { element ->
|
||||
val obj = element.jsonObjectOrNull() ?: return@mapNotNull null
|
||||
val points = obj.array("points")
|
||||
?.mapNotNull { pointElement ->
|
||||
val point = pointElement.jsonObjectOrNull() ?: return@mapNotNull null
|
||||
PdfPagePoint(
|
||||
x = point.float("x") ?: return@mapNotNull null,
|
||||
y = point.float("y") ?: return@mapNotNull null,
|
||||
timestamp = point.long("t") ?: point.long("timestamp") ?: 0L
|
||||
)
|
||||
}
|
||||
.orEmpty()
|
||||
if (points.isEmpty()) return@mapNotNull null
|
||||
|
||||
val tool = obj.string("inkType")
|
||||
?: obj.string("type")
|
||||
?: PdfInkTool.PEN.name
|
||||
SharedPdfAnnotation(
|
||||
id = obj.string("id") ?: stableAnnotationId("ink", element),
|
||||
pageIndex = obj.int("pageIndex") ?: return@mapNotNull null,
|
||||
kind = PdfAnnotationKind.INK,
|
||||
tool = tool.toPdfInkTool(),
|
||||
points = points,
|
||||
note = obj.string("note"),
|
||||
colorArgb = obj.int("color") ?: SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).colorArgb,
|
||||
strokeWidth = obj.float("strokeWidth") ?: SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).strokeWidth,
|
||||
createdAt = points.firstOrNull()?.timestamp ?: 0L
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.parseLegacyAndroidTextBoxes(): List<SharedPdfAnnotation> {
|
||||
val array = this?.jsonArrayOrNull() ?: return emptyList()
|
||||
return array.mapNotNull { element ->
|
||||
val obj = element.jsonObjectOrNull() ?: return@mapNotNull null
|
||||
val bounds = obj.objectValue("bounds")?.toPdfPageBoundsOrNull() ?: return@mapNotNull null
|
||||
val rawFontSize = obj.float("fontSize") ?: 16f
|
||||
SharedPdfAnnotation(
|
||||
id = obj.string("id") ?: stableAnnotationId("text", element),
|
||||
pageIndex = obj.int("pageIndex") ?: return@mapNotNull null,
|
||||
kind = PdfAnnotationKind.TEXT,
|
||||
tool = PdfInkTool.TEXT,
|
||||
bounds = bounds,
|
||||
text = obj.string("text").orEmpty(),
|
||||
colorArgb = obj.int("color") ?: 0xFF000000.toInt(),
|
||||
backgroundArgb = obj.int("backgroundColor") ?: 0x00000000,
|
||||
strokeWidth = SharedPdfAnnotationDefaults.configFor(PdfInkTool.TEXT).strokeWidth,
|
||||
fontSize = SharedPdfTextAnnotationDefaults.pageRelativeFontSizeToDisplay(rawFontSize),
|
||||
pageRelativeFontSize = SharedPdfTextAnnotationDefaults.legacyFontSizeToPageRelative(rawFontSize),
|
||||
isBold = obj.boolean("isBold") ?: false,
|
||||
isItalic = obj.boolean("isItalic") ?: false,
|
||||
isUnderline = obj.boolean("isUnderline") ?: false,
|
||||
isStrikeThrough = obj.boolean("isStrikeThrough") ?: false,
|
||||
fontPath = obj.string("fontPath"),
|
||||
fontName = obj.string("fontName")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.parseLegacyAndroidHighlights(): List<SharedPdfAnnotation> {
|
||||
val array = this?.jsonArrayOrNull() ?: return emptyList()
|
||||
return array.mapNotNull { element ->
|
||||
val obj = element.jsonObjectOrNull() ?: return@mapNotNull null
|
||||
val boundsList = obj.array("bounds")
|
||||
?.mapNotNull { it.jsonObjectOrNull()?.toPdfPageBoundsOrNull() }
|
||||
?.filter { it.isNormalizedPageBounds() }
|
||||
.orEmpty()
|
||||
val rangeStart = obj.int("rangeStart")
|
||||
val rangeEnd = obj.int("rangeEnd")
|
||||
if (boundsList.isEmpty() && (rangeStart == null || rangeEnd == null)) return@mapNotNull null
|
||||
val inclusiveRangeEnd = if (rangeStart != null && rangeEnd != null) {
|
||||
(rangeEnd - 1).coerceAtLeast(rangeStart)
|
||||
} else {
|
||||
rangeEnd
|
||||
}
|
||||
|
||||
val colorName = obj.string("color") ?: "YELLOW"
|
||||
SharedPdfAnnotation(
|
||||
id = obj.string("id") ?: stableAnnotationId("highlight", element),
|
||||
pageIndex = obj.int("pageIndex") ?: return@mapNotNull null,
|
||||
kind = PdfAnnotationKind.HIGHLIGHT,
|
||||
tool = PdfInkTool.HIGHLIGHTER,
|
||||
bounds = boundsList.firstOrNull(),
|
||||
boundsList = boundsList,
|
||||
text = obj.string("text").orEmpty(),
|
||||
note = obj.string("note"),
|
||||
comments = obj.array("comments").toSharedPdfAnnotationComments(),
|
||||
colorArgb = SharedPdfAndroidHighlightColors.argbForName(colorName),
|
||||
rangeStartIndex = rangeStart,
|
||||
rangeEndIndex = inclusiveRangeEnd
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<SharedPdfAnnotation>.toLegacyAndroidInkArray(): JsonArray {
|
||||
return JsonArray(
|
||||
filter { it.kind == PdfAnnotationKind.INK && it.points.isNotEmpty() }
|
||||
.map { annotation ->
|
||||
JsonObject(
|
||||
buildMap {
|
||||
put("id", JsonPrimitive(annotation.id))
|
||||
put("pageIndex", JsonPrimitive(annotation.pageIndex))
|
||||
put("annotationType", JsonPrimitive("INK"))
|
||||
put("inkType", JsonPrimitive(annotation.tool.name))
|
||||
put("color", JsonPrimitive(annotation.colorArgb))
|
||||
put("strokeWidth", JsonPrimitive(annotation.strokeWidth.toDouble()))
|
||||
annotation.note?.takeIf { it.isNotBlank() }?.let { put("note", JsonPrimitive(it)) }
|
||||
put(
|
||||
"points",
|
||||
JsonArray(
|
||||
annotation.points.map { point ->
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"x" to JsonPrimitive(point.x.toDouble()),
|
||||
"y" to JsonPrimitive(point.y.toDouble()),
|
||||
"t" to JsonPrimitive(point.timestamp)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<SharedPdfAnnotation>.toLegacyAndroidTextBoxArray(): JsonArray {
|
||||
return JsonArray(
|
||||
filter { it.kind == PdfAnnotationKind.TEXT && it.bounds != null }
|
||||
.map { annotation ->
|
||||
val bounds = requireNotNull(annotation.bounds)
|
||||
JsonObject(
|
||||
buildMap {
|
||||
put("id", JsonPrimitive(annotation.id))
|
||||
put("pageIndex", JsonPrimitive(annotation.pageIndex))
|
||||
put("text", JsonPrimitive(annotation.text))
|
||||
put("color", JsonPrimitive(annotation.colorArgb))
|
||||
put("backgroundColor", JsonPrimitive(annotation.backgroundArgb))
|
||||
put("fontSize", JsonPrimitive(annotation.sharedPdfTextPageRelativeFontSize().toDouble()))
|
||||
put("isBold", JsonPrimitive(annotation.isBold))
|
||||
put("isItalic", JsonPrimitive(annotation.isItalic))
|
||||
put("isUnderline", JsonPrimitive(annotation.isUnderline))
|
||||
put("isStrikeThrough", JsonPrimitive(annotation.isStrikeThrough))
|
||||
annotation.fontPath?.let { put("fontPath", JsonPrimitive(it)) }
|
||||
annotation.fontName?.let { put("fontName", JsonPrimitive(it)) }
|
||||
put("bounds", bounds.toJsonObject())
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<SharedPdfAnnotation>.toLegacyAndroidHighlightArray(): JsonArray {
|
||||
return JsonArray(
|
||||
filter { it.kind == PdfAnnotationKind.HIGHLIGHT }
|
||||
.map { annotation ->
|
||||
JsonObject(
|
||||
buildMap {
|
||||
put("id", JsonPrimitive(annotation.id))
|
||||
put("pageIndex", JsonPrimitive(annotation.pageIndex))
|
||||
put("color", JsonPrimitive(SharedPdfAndroidHighlightColors.nearestName(annotation.colorArgb)))
|
||||
put("text", JsonPrimitive(annotation.text))
|
||||
val rangeStart = annotation.rangeStartIndex ?: 0
|
||||
val rangeEnd = annotation.rangeEndIndex?.plus(1)?.coerceAtLeast(rangeStart) ?: rangeStart
|
||||
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()))
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseObjectOrNull(raw: String): JsonObject? {
|
||||
return runCatching { json.parseToJsonElement(raw).jsonObject }.getOrNull()
|
||||
}
|
||||
|
||||
private fun List<SharedPdfAnnotation>.sortedForSync(): List<SharedPdfAnnotation> {
|
||||
return sortedWith(
|
||||
compareBy<SharedPdfAnnotation> { it.pageIndex }
|
||||
.thenBy { it.createdAt.takeIf { timestamp -> timestamp > 0L } ?: Long.MAX_VALUE }
|
||||
.thenBy { it.id }
|
||||
)
|
||||
}
|
||||
|
||||
private fun mergeAnnotationDeletions(
|
||||
local: Map<String, Long>,
|
||||
remote: Map<String, Long>
|
||||
): Map<String, Long> {
|
||||
if (local.isEmpty()) return remote
|
||||
if (remote.isEmpty()) return local
|
||||
return buildMap {
|
||||
(local.keys + remote.keys).forEach { id ->
|
||||
put(id, maxOf(local[id] ?: 0L, remote[id] ?: 0L))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.parseAnnotationDeletions(): Map<String, Long> {
|
||||
val element = this ?: return emptyMap()
|
||||
val array = element.jsonArrayOrNull()
|
||||
?: element.jsonObjectOrNull()?.array(KEY_PDF_ANNOTATION_DELETIONS)
|
||||
?: return emptyMap()
|
||||
return buildMap {
|
||||
array.forEach { item ->
|
||||
val primitiveId = item.jsonPrimitiveOrNull()?.contentOrNull
|
||||
val obj = item.jsonObjectOrNull()
|
||||
val id = primitiveId?.takeIf { it.isNotBlank() } ?: obj?.string("id")
|
||||
if (id.isNullOrBlank()) return@forEach
|
||||
val deletedAt = obj?.long("deletedAt")
|
||||
?: obj?.long("timestamp")
|
||||
?: obj?.long("ts")
|
||||
?: 0L
|
||||
put(id, maxOf(this[id] ?: 0L, deletedAt))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stableAnnotationId(prefix: String, element: JsonElement): String {
|
||||
return "${prefix}_${localFolderSyncSha256ShortHex(json.encodeToString(JsonElement.serializer(), element))}"
|
||||
}
|
||||
|
||||
private fun JsonElement.looksLikeSharedAnnotationStore(): Boolean {
|
||||
val obj = jsonObjectOrNull()
|
||||
if (obj?.array("annotations") != null) return true
|
||||
val array = jsonArrayOrNull() ?: return false
|
||||
val first = array.firstOrNull()?.jsonObjectOrNull() ?: return false
|
||||
return first["kind"] != null && first["colorArgb"] != null
|
||||
}
|
||||
|
||||
private fun JsonElement?.isLegacyAndroidInkArray(): Boolean {
|
||||
val array = this?.jsonArrayOrNull() ?: return false
|
||||
if (array.isEmpty()) return true
|
||||
return array.all { element ->
|
||||
val obj = element.jsonObjectOrNull() ?: return@all false
|
||||
obj["kind"] == null &&
|
||||
obj["points"] != null &&
|
||||
(obj["annotationType"] != null || obj["inkType"] != null || obj["type"] != null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.isJsonArray(): Boolean = this?.jsonArrayOrNull() != null
|
||||
|
||||
private fun JsonObject.hasLegacyAndroidAnnotationPayload(): Boolean {
|
||||
return this[KEY_LEGACY_INK] != null ||
|
||||
this[KEY_LEGACY_TEXT_BOXES] != null ||
|
||||
this[KEY_LEGACY_HIGHLIGHTS] != null
|
||||
}
|
||||
|
||||
private fun JsonObject.sidecarDataObject(): JsonObject {
|
||||
return this["data"]?.jsonObjectOrNull() ?: this
|
||||
}
|
||||
|
||||
private fun JsonElement.jsonArrayOrNull(): JsonArray? {
|
||||
if (this is JsonNull) return null
|
||||
return runCatching { jsonArray }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonElement.jsonObjectOrNull(): JsonObject? {
|
||||
if (this is JsonNull) return null
|
||||
return runCatching { jsonObject }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonElement.jsonPrimitiveOrNull(): JsonPrimitive? {
|
||||
if (this is JsonNull) return null
|
||||
return runCatching { jsonPrimitive }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.array(name: String): JsonArray? = this[name]?.jsonArrayOrNull()
|
||||
|
||||
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()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun JsonObject.int(name: String): Int? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.long(name: String): Long? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.longOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.float(name: String): Float? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.doubleOrNull?.toFloat() }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.boolean(name: String): Boolean? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.booleanOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.toPdfPageBoundsOrNull(): PdfPageBounds? {
|
||||
val left = float("left") ?: return null
|
||||
val top = float("top") ?: return null
|
||||
val right = float("right") ?: return null
|
||||
val bottom = float("bottom") ?: return null
|
||||
return PdfPageBounds(
|
||||
left = minOf(left, right),
|
||||
top = minOf(top, bottom),
|
||||
right = maxOf(left, right),
|
||||
bottom = maxOf(top, bottom)
|
||||
)
|
||||
}
|
||||
|
||||
private fun PdfPageBounds.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"left" to JsonPrimitive(left.toDouble()),
|
||||
"top" to JsonPrimitive(top.toDouble()),
|
||||
"right" to JsonPrimitive(right.toDouble()),
|
||||
"bottom" to JsonPrimitive(bottom.toDouble())
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun PdfPageBounds.isNormalizedPageBounds(): Boolean {
|
||||
return left in 0f..1f &&
|
||||
top in 0f..1f &&
|
||||
right in 0f..1f &&
|
||||
bottom in 0f..1f &&
|
||||
right >= left &&
|
||||
bottom >= top
|
||||
}
|
||||
|
||||
private fun String.toPdfInkTool(): PdfInkTool {
|
||||
return runCatching { PdfInkTool.valueOf(this) }.getOrDefault(PdfInkTool.PEN)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,415 @@
|
|||
package org.dueattendant149.bookreader.shared.pdf
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
sealed interface SharedPdfInkRenderData {
|
||||
data class Standard(
|
||||
val path: Path,
|
||||
val color: Color,
|
||||
val strokeWidthPx: Float,
|
||||
val cap: StrokeCap,
|
||||
val blendMode: BlendMode
|
||||
) : SharedPdfInkRenderData
|
||||
|
||||
data class Fountain(
|
||||
val path: Path,
|
||||
val color: Color
|
||||
) : SharedPdfInkRenderData
|
||||
|
||||
data class Pencil(
|
||||
val path: Path,
|
||||
val color: Color,
|
||||
val strokeWidthPx: Float,
|
||||
val velocityAlpha: Float
|
||||
) : SharedPdfInkRenderData
|
||||
}
|
||||
|
||||
object SharedPdfInkRenderer {
|
||||
fun createRenderData(
|
||||
annotation: SharedPdfAnnotation,
|
||||
canvasSize: IntSize
|
||||
): SharedPdfInkRenderData? {
|
||||
if (annotation.kind != PdfAnnotationKind.INK || annotation.points.isEmpty()) return null
|
||||
if (annotation.tool == PdfInkTool.NONE) return null
|
||||
val widthPx = canvasSize.width.coerceAtLeast(1).toFloat()
|
||||
val heightPx = canvasSize.height.coerceAtLeast(1).toFloat()
|
||||
val strokeWidthPx = effectiveStrokeWidthPx(annotation.strokeWidth, widthPx)
|
||||
val color = Color(annotation.colorArgb)
|
||||
|
||||
if (annotation.points.size == 1) {
|
||||
val point = annotation.points.first()
|
||||
val x = point.x * widthPx
|
||||
val y = point.y * heightPx
|
||||
return when (annotation.tool) {
|
||||
PdfInkTool.FOUNTAIN_PEN -> {
|
||||
val path = Path().apply {
|
||||
addOval(Rect(center = Offset(x, y), radius = strokeWidthPx / 2f))
|
||||
}
|
||||
SharedPdfInkRenderData.Fountain(path = path, color = color)
|
||||
}
|
||||
PdfInkTool.PENCIL -> {
|
||||
val path = Path().apply {
|
||||
moveTo(x, y)
|
||||
lineTo(x, y)
|
||||
}
|
||||
SharedPdfInkRenderData.Pencil(
|
||||
path = path,
|
||||
color = color,
|
||||
strokeWidthPx = strokeWidthPx,
|
||||
velocityAlpha = 1f
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
val path = Path().apply {
|
||||
moveTo(x, y)
|
||||
lineTo(x, y)
|
||||
}
|
||||
SharedPdfInkRenderData.Standard(
|
||||
path = path,
|
||||
color = color,
|
||||
strokeWidthPx = strokeWidthPx,
|
||||
cap = annotation.tool.strokeCap,
|
||||
blendMode = annotation.tool.blendMode
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return when (annotation.tool) {
|
||||
PdfInkTool.NONE -> null
|
||||
PdfInkTool.PENCIL -> {
|
||||
val path = annotation.points.toSmoothPath(widthPx, heightPx)
|
||||
val velocityAlpha = annotation.points.velocityAlpha(widthPx, heightPx)
|
||||
SharedPdfInkRenderData.Pencil(
|
||||
path = path,
|
||||
color = color,
|
||||
strokeWidthPx = strokeWidthPx,
|
||||
velocityAlpha = velocityAlpha
|
||||
)
|
||||
}
|
||||
PdfInkTool.FOUNTAIN_PEN -> {
|
||||
val (leftSide, rightSide) = calculateFountainPenEdges(
|
||||
points = annotation.points,
|
||||
baseWidthPx = strokeWidthPx,
|
||||
pageWidthPx = widthPx,
|
||||
pageHeightPx = heightPx
|
||||
)
|
||||
val path = Path()
|
||||
if (leftSide.isNotEmpty()) {
|
||||
path.moveTo(leftSide.first().x, leftSide.first().y)
|
||||
leftSide.drop(1).forEach { path.lineTo(it.x, it.y) }
|
||||
rightSide.asReversed().forEach { path.lineTo(it.x, it.y) }
|
||||
path.close()
|
||||
}
|
||||
SharedPdfInkRenderData.Fountain(path = path, color = color)
|
||||
}
|
||||
PdfInkTool.PEN,
|
||||
PdfInkTool.HIGHLIGHTER,
|
||||
PdfInkTool.HIGHLIGHTER_ROUND,
|
||||
PdfInkTool.ERASER,
|
||||
PdfInkTool.TEXT -> {
|
||||
SharedPdfInkRenderData.Standard(
|
||||
path = annotation.points.toSmoothPath(widthPx, heightPx),
|
||||
color = color,
|
||||
strokeWidthPx = strokeWidthPx,
|
||||
cap = annotation.tool.strokeCap,
|
||||
blendMode = annotation.tool.blendMode
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun effectiveStrokeWidthPx(strokeWidth: Float, canvasSize: IntSize): Float {
|
||||
return effectiveStrokeWidthPx(strokeWidth, canvasSize.width.coerceAtLeast(1).toFloat())
|
||||
}
|
||||
|
||||
fun effectiveStrokeWidthPx(strokeWidth: Float, pageWidthPx: Float): Float {
|
||||
val safeWidth = pageWidthPx.coerceAtLeast(1f)
|
||||
return if (strokeWidth <= 1f) {
|
||||
(strokeWidth * safeWidth).coerceAtLeast(0.1f)
|
||||
} else {
|
||||
strokeWidth.coerceAtLeast(0.1f)
|
||||
}
|
||||
}
|
||||
|
||||
fun effectiveStrokeWidthNorm(strokeWidth: Float, pageWidthPx: Float): Float {
|
||||
val safeWidth = pageWidthPx.coerceAtLeast(1f)
|
||||
return if (strokeWidth <= 1f) strokeWidth.coerceAtLeast(0.0001f) else strokeWidth / safeWidth
|
||||
}
|
||||
|
||||
fun calculateSnappedPoint(
|
||||
currentPoint: PdfPagePoint,
|
||||
startPoint: PdfPagePoint?,
|
||||
pageAspectRatio: Float,
|
||||
thresholdDegrees: Double = 10.0
|
||||
): PdfPagePoint {
|
||||
if (startPoint == null) return currentPoint
|
||||
val safeAspectRatio = pageAspectRatio.takeIf { it > 0f } ?: 1f
|
||||
val dx = (currentPoint.x - startPoint.x) * safeAspectRatio
|
||||
val dy = currentPoint.y - startPoint.y
|
||||
val angleDeg = atan2(dy, dx) * 180 / PI
|
||||
val absAngle = abs(angleDeg)
|
||||
val isHorizontal = absAngle < thresholdDegrees || abs(absAngle - 180.0) < thresholdDegrees
|
||||
val isVertical = abs(absAngle - 90.0) < thresholdDegrees
|
||||
return when {
|
||||
isHorizontal -> currentPoint.copy(y = startPoint.y)
|
||||
isVertical -> currentPoint.copy(x = startPoint.x)
|
||||
else -> currentPoint
|
||||
}
|
||||
}
|
||||
|
||||
fun isAnnotationHit(
|
||||
annotation: SharedPdfAnnotation,
|
||||
hitPoint: PdfPagePoint,
|
||||
pageWidthPx: Float,
|
||||
pageAspectRatio: Float,
|
||||
eraserStrokeWidth: Float = SharedPdfAnnotationDefaults.configFor(PdfInkTool.ERASER).strokeWidth,
|
||||
lastHitPoint: PdfPagePoint? = null
|
||||
): Boolean {
|
||||
return when (annotation.kind) {
|
||||
PdfAnnotationKind.HIGHLIGHT,
|
||||
PdfAnnotationKind.TEXT -> annotation.allBounds().any { it.contains(hitPoint.x, hitPoint.y) }
|
||||
PdfAnnotationKind.INK -> isInkAnnotationHit(
|
||||
annotation = annotation,
|
||||
hitPoint = hitPoint,
|
||||
pageWidthPx = pageWidthPx,
|
||||
pageAspectRatio = pageAspectRatio,
|
||||
eraserStrokeWidth = eraserStrokeWidth,
|
||||
lastHitPoint = lastHitPoint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isInkAnnotationHit(
|
||||
annotation: SharedPdfAnnotation,
|
||||
hitPoint: PdfPagePoint,
|
||||
pageWidthPx: Float,
|
||||
pageAspectRatio: Float,
|
||||
eraserStrokeWidth: Float,
|
||||
lastHitPoint: PdfPagePoint?
|
||||
): Boolean {
|
||||
if (annotation.points.isEmpty()) return false
|
||||
val safeAspectRatio = pageAspectRatio.takeIf { it > 0f } ?: 1f
|
||||
val eraserWidthNorm = effectiveStrokeWidthNorm(eraserStrokeWidth, pageWidthPx)
|
||||
val annotationWidthNorm = effectiveStrokeWidthNorm(annotation.strokeWidth, pageWidthPx)
|
||||
val threshold = eraserWidthNorm + annotationWidthNorm / 2f
|
||||
val thresholdSq = threshold * threshold
|
||||
|
||||
fun distSqToEraser(px: Float, pyScaled: Float): Float {
|
||||
val e1x = hitPoint.x
|
||||
val e1yScaled = hitPoint.y / safeAspectRatio
|
||||
if (lastHitPoint == null) {
|
||||
val dx = px - e1x
|
||||
val dy = pyScaled - e1yScaled
|
||||
return dx * dx + dy * dy
|
||||
}
|
||||
|
||||
val e0x = lastHitPoint.x
|
||||
val e0yScaled = lastHitPoint.y / safeAspectRatio
|
||||
val ex = e1x - e0x
|
||||
val ey = e1yScaled - e0yScaled
|
||||
val segmentLenSq = ex * ex + ey * ey
|
||||
if (segmentLenSq < 1e-8f) {
|
||||
val dx = px - e1x
|
||||
val dy = pyScaled - e1yScaled
|
||||
return dx * dx + dy * dy
|
||||
}
|
||||
|
||||
val t = ((px - e0x) * ex + (pyScaled - e0yScaled) * ey) / segmentLenSq
|
||||
val closestX = e0x + ex * t.coerceIn(0f, 1f)
|
||||
val closestY = e0yScaled + ey * t.coerceIn(0f, 1f)
|
||||
val dx = px - closestX
|
||||
val dy = pyScaled - closestY
|
||||
return dx * dx + dy * dy
|
||||
}
|
||||
|
||||
if (annotation.points.size == 1) {
|
||||
val p = annotation.points.first()
|
||||
return distSqToEraser(p.x, p.y / safeAspectRatio) < thresholdSq
|
||||
}
|
||||
|
||||
for (i in 0 until annotation.points.lastIndex) {
|
||||
val a = annotation.points[i]
|
||||
val b = annotation.points[i + 1]
|
||||
val pax = hitPoint.x - a.x
|
||||
val pay = (hitPoint.y - a.y) / safeAspectRatio
|
||||
val bax = b.x - a.x
|
||||
val bay = (b.y - a.y) / safeAspectRatio
|
||||
val segmentLenSq = (bax * bax + bay * bay).coerceAtLeast(1e-6f)
|
||||
val t = ((pax * bax + pay * bay) / segmentLenSq).coerceIn(0f, 1f)
|
||||
val closestX = bax * t
|
||||
val closestY = bay * t
|
||||
val dx = pax - closestX
|
||||
val dy = pay - closestY
|
||||
if (dx * dx + dy * dy < thresholdSq) return true
|
||||
|
||||
if (lastHitPoint != null) {
|
||||
if (distSqToEraser(a.x, a.y / safeAspectRatio) < thresholdSq) return true
|
||||
if (distSqToEraser(b.x, b.y / safeAspectRatio) < thresholdSq) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun calculateFountainPenEdges(
|
||||
points: List<PdfPagePoint>,
|
||||
baseWidthPx: Float,
|
||||
pageWidthPx: Float,
|
||||
pageHeightPx: Float
|
||||
): Pair<List<Offset>, List<Offset>> {
|
||||
if (points.size < 2) return emptyList<Offset>() to emptyList<Offset>()
|
||||
|
||||
val leftSide = mutableListOf<Offset>()
|
||||
val rightSide = mutableListOf<Offset>()
|
||||
val computedWidths = FloatArray(points.size)
|
||||
computedWidths[0] = baseWidthPx
|
||||
val velocityFactor = 300f
|
||||
|
||||
for (i in 1 until points.size) {
|
||||
val p0 = points[i - 1]
|
||||
val p1 = points[i]
|
||||
val dx = p1.x - p0.x
|
||||
val dy = p1.y - p0.y
|
||||
val aspect = if (pageWidthPx > 0f && pageHeightPx > 0f) pageHeightPx / pageWidthPx else 1f
|
||||
val scaledDy = dy * aspect
|
||||
val distNorm = sqrt(dx * dx + scaledDy * scaledDy)
|
||||
val timeDelta = (p1.timestamp - p0.timestamp).coerceAtLeast(1)
|
||||
val velocityNorm = distNorm / timeDelta
|
||||
val targetWidth = (baseWidthPx * (1f / (1f + velocityNorm * velocityFactor))).coerceIn(
|
||||
baseWidthPx * 0.2f,
|
||||
baseWidthPx * 1.4f
|
||||
)
|
||||
computedWidths[i] = computedWidths[i - 1] * 0.6f + targetWidth * 0.4f
|
||||
}
|
||||
|
||||
for (i in 0 until points.lastIndex) {
|
||||
val current = points[i]
|
||||
val next = points[i + 1]
|
||||
val currentX = current.x * pageWidthPx
|
||||
val currentY = current.y * pageHeightPx
|
||||
val nextX = next.x * pageWidthPx
|
||||
val nextY = next.y * pageHeightPx
|
||||
val angle = atan2(nextY - currentY, nextX - currentX)
|
||||
val normalAngle = angle - (PI / 2f).toFloat()
|
||||
val halfWidth = computedWidths[i] / 2f
|
||||
leftSide += Offset(
|
||||
x = currentX + cos(normalAngle) * halfWidth,
|
||||
y = currentY + sin(normalAngle) * halfWidth
|
||||
)
|
||||
rightSide += Offset(
|
||||
x = currentX - cos(normalAngle) * halfWidth,
|
||||
y = currentY - sin(normalAngle) * halfWidth
|
||||
)
|
||||
}
|
||||
|
||||
val last = points.last()
|
||||
val previous = points[points.lastIndex - 1]
|
||||
val lastX = last.x * pageWidthPx
|
||||
val lastY = last.y * pageHeightPx
|
||||
val previousX = previous.x * pageWidthPx
|
||||
val previousY = previous.y * pageHeightPx
|
||||
val lastAngle = atan2(lastY - previousY, lastX - previousX)
|
||||
val lastNormal = lastAngle - (PI / 2f).toFloat()
|
||||
val lastHalfWidth = computedWidths.last() / 2f
|
||||
leftSide += Offset(
|
||||
x = lastX + cos(lastNormal) * lastHalfWidth,
|
||||
y = lastY + sin(lastNormal) * lastHalfWidth
|
||||
)
|
||||
rightSide += Offset(
|
||||
x = lastX - cos(lastNormal) * lastHalfWidth,
|
||||
y = lastY - sin(lastNormal) * lastHalfWidth
|
||||
)
|
||||
return leftSide to rightSide
|
||||
}
|
||||
}
|
||||
|
||||
fun PdfInkTool.sharedPdfStrokeWidthRange(): ClosedFloatingPointRange<Float> {
|
||||
return when (this) {
|
||||
PdfInkTool.NONE -> 0.001f..0.015f
|
||||
PdfInkTool.HIGHLIGHTER,
|
||||
PdfInkTool.HIGHLIGHTER_ROUND -> 0.01f..0.06f
|
||||
PdfInkTool.ERASER -> 0.002f..0.10f
|
||||
PdfInkTool.TEXT -> 0.01f..0.08f
|
||||
PdfInkTool.PEN,
|
||||
PdfInkTool.FOUNTAIN_PEN,
|
||||
PdfInkTool.PENCIL -> 0.001f..0.015f
|
||||
}
|
||||
}
|
||||
|
||||
fun Float.sharedPdfStrokePercent(range: ClosedFloatingPointRange<Float>): Int {
|
||||
val span = (range.endInclusive - range.start).coerceAtLeast(0.0001f)
|
||||
return (((this - range.start) / span) * 100f).toInt().coerceIn(1, 100)
|
||||
}
|
||||
|
||||
private val PdfInkTool.strokeCap: StrokeCap
|
||||
get() = when (this) {
|
||||
PdfInkTool.HIGHLIGHTER -> StrokeCap.Butt
|
||||
PdfInkTool.HIGHLIGHTER_ROUND -> StrokeCap.Round
|
||||
else -> StrokeCap.Round
|
||||
}
|
||||
|
||||
private val PdfInkTool.blendMode: BlendMode
|
||||
get() = when (this) {
|
||||
PdfInkTool.HIGHLIGHTER,
|
||||
PdfInkTool.HIGHLIGHTER_ROUND -> BlendMode.Multiply
|
||||
else -> BlendMode.SrcOver
|
||||
}
|
||||
|
||||
private fun PdfPageBounds.contains(x: Float, y: Float): Boolean {
|
||||
return x in left..right && y in top..bottom
|
||||
}
|
||||
|
||||
private fun SharedPdfAnnotation.allBounds(): List<PdfPageBounds> {
|
||||
return boundsList.ifEmpty { listOfNotNull(bounds) }
|
||||
}
|
||||
|
||||
private fun List<PdfPagePoint>.toSmoothPath(widthPx: Float, heightPx: Float): Path {
|
||||
val path = Path()
|
||||
val first = first()
|
||||
path.moveTo(first.x * widthPx, first.y * heightPx)
|
||||
for (i in 1 until size) {
|
||||
val p0 = this[i - 1]
|
||||
val p1 = this[i]
|
||||
val p0x = p0.x * widthPx
|
||||
val p0y = p0.y * heightPx
|
||||
val p1x = p1.x * widthPx
|
||||
val p1y = p1.y * heightPx
|
||||
val midX = (p0x + p1x) / 2f
|
||||
val midY = (p0y + p1y) / 2f
|
||||
if (i == 1) {
|
||||
path.lineTo(midX, midY)
|
||||
} else {
|
||||
path.quadraticTo(p0x, p0y, midX, midY)
|
||||
}
|
||||
}
|
||||
val last = last()
|
||||
path.lineTo(last.x * widthPx, last.y * heightPx)
|
||||
return path
|
||||
}
|
||||
|
||||
private fun List<PdfPagePoint>.velocityAlpha(widthPx: Float, heightPx: Float): Float {
|
||||
if (size < 2) return 1f
|
||||
var totalDistance = 0f
|
||||
for (i in 1 until size) {
|
||||
val p0 = this[i - 1]
|
||||
val p1 = this[i]
|
||||
val dx = (p1.x - p0.x) * widthPx
|
||||
val dy = (p1.y - p0.y) * heightPx
|
||||
totalDistance += sqrt(dx * dx + dy * dy)
|
||||
}
|
||||
val duration = (last().timestamp - first().timestamp).coerceAtLeast(1)
|
||||
val velocity = totalDistance / duration
|
||||
return (1f - (velocity - 0.2f) / 1.8f).coerceIn(0.4f, 1f)
|
||||
}
|
||||
|
|
@ -0,0 +1,397 @@
|
|||
package org.dueattendant149.bookreader.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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'")
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,413 @@
|
|||
package org.dueattendant149.bookreader.shared.pdf
|
||||
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.math.ceil
|
||||
|
||||
@Serializable
|
||||
data class SharedPdfTextStyleConfig(
|
||||
val colorArgb: Int = 0xFF000000.toInt(),
|
||||
val backgroundColorArgb: Int = 0x00000000,
|
||||
val fontSize: Float = 16f,
|
||||
val pageRelativeFontSize: Float? = null,
|
||||
val isBold: Boolean = false,
|
||||
val isItalic: Boolean = false,
|
||||
val isUnderline: Boolean = false,
|
||||
val isStrikeThrough: Boolean = false,
|
||||
val fontPath: String? = null,
|
||||
val fontName: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SharedPdfTextFontPreset(
|
||||
val name: String,
|
||||
val fontPath: String? = null
|
||||
)
|
||||
|
||||
enum class SharedPdfTextResizeHandle {
|
||||
TOP_LEFT,
|
||||
TOP_CENTER,
|
||||
TOP_RIGHT,
|
||||
RIGHT_CENTER,
|
||||
BOTTOM_RIGHT,
|
||||
BOTTOM_CENTER,
|
||||
BOTTOM_LEFT,
|
||||
LEFT_CENTER
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SharedPdfTextDraft(
|
||||
val id: String,
|
||||
val pageIndex: Int,
|
||||
val bounds: PdfPageBounds,
|
||||
val text: String = "",
|
||||
val style: SharedPdfTextStyleConfig = SharedPdfTextStyleConfig(),
|
||||
val createdAt: Long = 0L,
|
||||
val isManuallySized: Boolean = false
|
||||
)
|
||||
|
||||
object SharedPdfTextAnnotationDefaults {
|
||||
private const val AndroidTextBoxFontReferencePx = 500f
|
||||
private const val MinPageRelativeFontSize = 0.012f
|
||||
private const val MaxPageRelativeFontSize = 0.12f
|
||||
|
||||
val fontSizes: List<Float> = listOf(12f, 14f, 16f, 18f, 20f, 24f, 30f)
|
||||
|
||||
val fontPresets: List<SharedPdfTextFontPreset> = listOf(
|
||||
SharedPdfTextFontPreset("Default"),
|
||||
SharedPdfTextFontPreset("Merriweather", "asset:fonts/merriweather.ttf"),
|
||||
SharedPdfTextFontPreset("Lato", "asset:fonts/lato.ttf"),
|
||||
SharedPdfTextFontPreset("Lora", "asset:fonts/lora.ttf"),
|
||||
SharedPdfTextFontPreset("Roboto Mono", "asset:fonts/roboto_mono.ttf"),
|
||||
SharedPdfTextFontPreset("Lexend", "asset:fonts/lexend.ttf")
|
||||
)
|
||||
|
||||
val textColorPalette: List<Int>
|
||||
get() = SharedPdfAnnotationDefaults.penPalette
|
||||
|
||||
val backgroundColorPalette: List<Int> = listOf(
|
||||
0x00000000,
|
||||
0x8CFF9800.toInt(),
|
||||
0x8CFFEB3B.toInt(),
|
||||
0x8C81C784.toInt(),
|
||||
0x8C64B5F6.toInt(),
|
||||
0x8CE1BEE7.toInt()
|
||||
)
|
||||
|
||||
fun normalizeTextDraft(text: String): String {
|
||||
return text
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
fun createAnnotation(
|
||||
id: String,
|
||||
pageIndex: Int,
|
||||
anchor: PdfPagePoint,
|
||||
canvasSize: IntSize,
|
||||
text: String,
|
||||
style: SharedPdfTextStyleConfig,
|
||||
createdAt: Long
|
||||
): SharedPdfAnnotation {
|
||||
val cleanText = normalizeTextDraft(text)
|
||||
return SharedPdfAnnotation(
|
||||
id = id,
|
||||
pageIndex = pageIndex,
|
||||
kind = PdfAnnotationKind.TEXT,
|
||||
tool = PdfInkTool.TEXT,
|
||||
bounds = boundsForPlacedText(anchor, canvasSize, cleanText, style),
|
||||
text = cleanText,
|
||||
colorArgb = style.colorArgb,
|
||||
backgroundArgb = style.backgroundColorArgb,
|
||||
strokeWidth = SharedPdfAnnotationDefaults.configFor(PdfInkTool.TEXT).strokeWidth,
|
||||
fontSize = style.fontSize,
|
||||
pageRelativeFontSize = style.sharedPdfTextPageRelativeFontSize(),
|
||||
isBold = style.isBold,
|
||||
isItalic = style.isItalic,
|
||||
isUnderline = style.isUnderline,
|
||||
isStrikeThrough = style.isStrikeThrough,
|
||||
fontPath = style.fontPath,
|
||||
fontName = style.fontName,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
|
||||
fun createDraft(
|
||||
id: String,
|
||||
pageIndex: Int,
|
||||
anchor: PdfPagePoint,
|
||||
canvasSize: IntSize,
|
||||
style: SharedPdfTextStyleConfig,
|
||||
createdAt: Long
|
||||
): SharedPdfTextDraft {
|
||||
return SharedPdfTextDraft(
|
||||
id = id,
|
||||
pageIndex = pageIndex,
|
||||
bounds = boundsForPlacedText(anchor, canvasSize, " ", style),
|
||||
text = "",
|
||||
style = style,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
|
||||
fun boundsForPlacedText(
|
||||
anchor: PdfPagePoint,
|
||||
canvasSize: IntSize,
|
||||
text: String,
|
||||
style: SharedPdfTextStyleConfig
|
||||
): PdfPageBounds {
|
||||
val widthPx = canvasSize.width.coerceAtLeast(1).toFloat()
|
||||
val heightPx = canvasSize.height.coerceAtLeast(1).toFloat()
|
||||
val fontSizePx = style.sharedPdfTextFontSizePx(canvasSize)
|
||||
val widthNorm = estimateWidthNorm(text, fontSizePx, widthPx).coerceIn(0.18f, 0.62f)
|
||||
val lineCount = estimateLineCount(text, fontSizePx, widthPx * widthNorm)
|
||||
val heightNorm = (((fontSizePx * 1.35f * lineCount) + 14f) / heightPx).coerceIn(0.04f, 0.36f)
|
||||
val left = anchor.x.coerceIn(0f, 1f - widthNorm)
|
||||
val top = anchor.y.coerceIn(0f, 1f - heightNorm)
|
||||
return PdfPageBounds(
|
||||
left = left,
|
||||
top = top,
|
||||
right = left + widthNorm,
|
||||
bottom = top + heightNorm
|
||||
)
|
||||
}
|
||||
|
||||
fun estimateLineCount(text: String, fontSize: Float, widthPx: Float): Int {
|
||||
if (text.isBlank()) return 1
|
||||
val averageCharWidth = (fontSize * 0.55f).coerceAtLeast(1f)
|
||||
val charsPerLine = (widthPx / averageCharWidth).toInt().coerceAtLeast(8)
|
||||
return text.lineSequence().sumOf { rawLine ->
|
||||
val length = rawLine.length.coerceAtLeast(1)
|
||||
ceil(length / charsPerLine.toFloat()).toInt().coerceAtLeast(1)
|
||||
}.coerceAtLeast(1)
|
||||
}
|
||||
|
||||
private fun estimateWidthNorm(
|
||||
text: String,
|
||||
fontSizePx: Float,
|
||||
pageWidthPx: Float
|
||||
): Float {
|
||||
val longestLine = text.lineSequence().maxOfOrNull { it.length } ?: 0
|
||||
val estimatedTextWidth = (longestLine.coerceAtLeast(12) * fontSizePx * 0.55f) + 18f
|
||||
return (estimatedTextWidth / pageWidthPx).coerceAtLeast(0.28f)
|
||||
}
|
||||
|
||||
internal fun displayFontSizeToPageRelative(fontSize: Float): Float {
|
||||
return (fontSize / AndroidTextBoxFontReferencePx)
|
||||
.coerceIn(MinPageRelativeFontSize, MaxPageRelativeFontSize)
|
||||
}
|
||||
|
||||
internal fun pageRelativeFontSizeToDisplay(fontSize: Float): Float {
|
||||
return if (fontSize in 0f..1f) {
|
||||
(fontSize * AndroidTextBoxFontReferencePx).coerceIn(8f, 48f)
|
||||
} else {
|
||||
fontSize.coerceIn(8f, 96f)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun legacyFontSizeToPageRelative(fontSize: Float): Float {
|
||||
return if (fontSize in 0f..1f) {
|
||||
fontSize.coerceIn(MinPageRelativeFontSize, MaxPageRelativeFontSize)
|
||||
} else {
|
||||
displayFontSizeToPageRelative(fontSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun SharedPdfTextDraft.withText(
|
||||
text: String,
|
||||
canvasSize: IntSize
|
||||
): SharedPdfTextDraft {
|
||||
val normalizedText = text
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
if (isManuallySized) {
|
||||
return copy(text = normalizedText)
|
||||
}
|
||||
val anchor = PdfPagePoint(bounds.left, bounds.top, createdAt)
|
||||
return copy(
|
||||
text = normalizedText,
|
||||
bounds = SharedPdfTextAnnotationDefaults.boundsForPlacedText(
|
||||
anchor = anchor,
|
||||
canvasSize = canvasSize,
|
||||
text = normalizedText.ifBlank { " " },
|
||||
style = style
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun SharedPdfTextDraft.withStyle(
|
||||
style: SharedPdfTextStyleConfig,
|
||||
canvasSize: IntSize
|
||||
): SharedPdfTextDraft {
|
||||
if (isManuallySized) {
|
||||
return copy(style = style)
|
||||
}
|
||||
val anchor = PdfPagePoint(bounds.left, bounds.top, createdAt)
|
||||
return copy(
|
||||
style = style,
|
||||
bounds = SharedPdfTextAnnotationDefaults.boundsForPlacedText(
|
||||
anchor = anchor,
|
||||
canvasSize = canvasSize,
|
||||
text = text.ifBlank { " " },
|
||||
style = style
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun SharedPdfTextDraft.withBounds(bounds: PdfPageBounds): SharedPdfTextDraft {
|
||||
return copy(bounds = bounds.coercedToPage(), isManuallySized = true)
|
||||
}
|
||||
|
||||
fun SharedPdfTextDraft.toAnnotation(): SharedPdfAnnotation {
|
||||
val cleanText = SharedPdfTextAnnotationDefaults.normalizeTextDraft(text)
|
||||
return SharedPdfAnnotation(
|
||||
id = id,
|
||||
pageIndex = pageIndex,
|
||||
kind = PdfAnnotationKind.TEXT,
|
||||
tool = PdfInkTool.TEXT,
|
||||
bounds = bounds,
|
||||
text = cleanText,
|
||||
colorArgb = style.colorArgb,
|
||||
backgroundArgb = style.backgroundColorArgb,
|
||||
strokeWidth = SharedPdfAnnotationDefaults.configFor(PdfInkTool.TEXT).strokeWidth,
|
||||
fontSize = style.fontSize,
|
||||
pageRelativeFontSize = style.sharedPdfTextPageRelativeFontSize(),
|
||||
isBold = style.isBold,
|
||||
isItalic = style.isItalic,
|
||||
isUnderline = style.isUnderline,
|
||||
isStrikeThrough = style.isStrikeThrough,
|
||||
fontPath = style.fontPath,
|
||||
fontName = style.fontName,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
|
||||
fun PdfPageBounds.resizedBy(
|
||||
handle: SharedPdfTextResizeHandle,
|
||||
deltaXPx: Float,
|
||||
deltaYPx: Float,
|
||||
canvasSize: IntSize,
|
||||
minWidthPx: Float = 50f,
|
||||
minHeightPx: Float = 50f
|
||||
): PdfPageBounds {
|
||||
val pageWidthPx = canvasSize.width.coerceAtLeast(1).toFloat()
|
||||
val pageHeightPx = canvasSize.height.coerceAtLeast(1).toFloat()
|
||||
val minWidth = minWidthPx.coerceIn(1f, pageWidthPx)
|
||||
val minHeight = minHeightPx.coerceIn(1f, pageHeightPx)
|
||||
|
||||
var leftPx = left * pageWidthPx
|
||||
var topPx = top * pageHeightPx
|
||||
var rightPx = right * pageWidthPx
|
||||
var bottomPx = bottom * pageHeightPx
|
||||
|
||||
when (handle) {
|
||||
SharedPdfTextResizeHandle.TOP_LEFT -> {
|
||||
leftPx = (leftPx + deltaXPx).coerceIn(0f, (rightPx - minWidth).coerceAtLeast(0f))
|
||||
topPx = (topPx + deltaYPx).coerceIn(0f, (bottomPx - minHeight).coerceAtLeast(0f))
|
||||
}
|
||||
SharedPdfTextResizeHandle.TOP_CENTER -> {
|
||||
topPx = (topPx + deltaYPx).coerceIn(0f, (bottomPx - minHeight).coerceAtLeast(0f))
|
||||
}
|
||||
SharedPdfTextResizeHandle.TOP_RIGHT -> {
|
||||
rightPx = (rightPx + deltaXPx).coerceIn((leftPx + minWidth).coerceAtMost(pageWidthPx), pageWidthPx)
|
||||
topPx = (topPx + deltaYPx).coerceIn(0f, (bottomPx - minHeight).coerceAtLeast(0f))
|
||||
}
|
||||
SharedPdfTextResizeHandle.RIGHT_CENTER -> {
|
||||
rightPx = (rightPx + deltaXPx).coerceIn((leftPx + minWidth).coerceAtMost(pageWidthPx), pageWidthPx)
|
||||
}
|
||||
SharedPdfTextResizeHandle.BOTTOM_RIGHT -> {
|
||||
rightPx = (rightPx + deltaXPx).coerceIn((leftPx + minWidth).coerceAtMost(pageWidthPx), pageWidthPx)
|
||||
bottomPx = (bottomPx + deltaYPx).coerceIn((topPx + minHeight).coerceAtMost(pageHeightPx), pageHeightPx)
|
||||
}
|
||||
SharedPdfTextResizeHandle.BOTTOM_CENTER -> {
|
||||
bottomPx = (bottomPx + deltaYPx).coerceIn((topPx + minHeight).coerceAtMost(pageHeightPx), pageHeightPx)
|
||||
}
|
||||
SharedPdfTextResizeHandle.BOTTOM_LEFT -> {
|
||||
leftPx = (leftPx + deltaXPx).coerceIn(0f, (rightPx - minWidth).coerceAtLeast(0f))
|
||||
bottomPx = (bottomPx + deltaYPx).coerceIn((topPx + minHeight).coerceAtMost(pageHeightPx), pageHeightPx)
|
||||
}
|
||||
SharedPdfTextResizeHandle.LEFT_CENTER -> {
|
||||
leftPx = (leftPx + deltaXPx).coerceIn(0f, (rightPx - minWidth).coerceAtLeast(0f))
|
||||
}
|
||||
}
|
||||
|
||||
return PdfPageBounds(
|
||||
left = leftPx / pageWidthPx,
|
||||
top = topPx / pageHeightPx,
|
||||
right = rightPx / pageWidthPx,
|
||||
bottom = bottomPx / pageHeightPx
|
||||
).coercedToPage()
|
||||
}
|
||||
|
||||
fun PdfPageBounds.movedBy(
|
||||
deltaXPx: Float,
|
||||
deltaYPx: Float,
|
||||
canvasSize: IntSize
|
||||
): PdfPageBounds {
|
||||
val pageWidthPx = canvasSize.width.coerceAtLeast(1).toFloat()
|
||||
val pageHeightPx = canvasSize.height.coerceAtLeast(1).toFloat()
|
||||
val widthPx = ((right - left) * pageWidthPx).coerceIn(1f, pageWidthPx)
|
||||
val heightPx = ((bottom - top) * pageHeightPx).coerceIn(1f, pageHeightPx)
|
||||
val nextLeftPx = ((left * pageWidthPx) + deltaXPx).coerceIn(0f, (pageWidthPx - widthPx).coerceAtLeast(0f))
|
||||
val nextTopPx = ((top * pageHeightPx) + deltaYPx).coerceIn(0f, (pageHeightPx - heightPx).coerceAtLeast(0f))
|
||||
return PdfPageBounds(
|
||||
left = nextLeftPx / pageWidthPx,
|
||||
top = nextTopPx / pageHeightPx,
|
||||
right = (nextLeftPx + widthPx) / pageWidthPx,
|
||||
bottom = (nextTopPx + heightPx) / pageHeightPx
|
||||
).coercedToPage()
|
||||
}
|
||||
|
||||
fun SharedPdfAnnotation.sharedPdfTextStyle(): SharedPdfTextStyleConfig {
|
||||
return SharedPdfTextStyleConfig(
|
||||
colorArgb = colorArgb,
|
||||
backgroundColorArgb = backgroundArgb,
|
||||
fontSize = fontSize,
|
||||
pageRelativeFontSize = pageRelativeFontSize,
|
||||
isBold = isBold,
|
||||
isItalic = isItalic,
|
||||
isUnderline = isUnderline,
|
||||
isStrikeThrough = isStrikeThrough,
|
||||
fontPath = fontPath,
|
||||
fontName = fontName
|
||||
)
|
||||
}
|
||||
|
||||
fun SharedPdfAnnotation.withSharedPdfTextStyle(style: SharedPdfTextStyleConfig): SharedPdfAnnotation {
|
||||
return copy(
|
||||
colorArgb = style.colorArgb,
|
||||
backgroundArgb = style.backgroundColorArgb,
|
||||
fontSize = style.fontSize,
|
||||
pageRelativeFontSize = style.sharedPdfTextPageRelativeFontSize(),
|
||||
isBold = style.isBold,
|
||||
isItalic = style.isItalic,
|
||||
isUnderline = style.isUnderline,
|
||||
isStrikeThrough = style.isStrikeThrough,
|
||||
fontPath = style.fontPath,
|
||||
fontName = style.fontName
|
||||
)
|
||||
}
|
||||
|
||||
fun SharedPdfTextStyleConfig.withSharedPdfTextFontSize(fontSize: Float): SharedPdfTextStyleConfig {
|
||||
return copy(
|
||||
fontSize = fontSize,
|
||||
pageRelativeFontSize = SharedPdfTextAnnotationDefaults.displayFontSizeToPageRelative(fontSize)
|
||||
)
|
||||
}
|
||||
|
||||
fun SharedPdfTextStyleConfig.sharedPdfTextPageRelativeFontSize(): Float {
|
||||
return pageRelativeFontSize
|
||||
?.let { SharedPdfTextAnnotationDefaults.legacyFontSizeToPageRelative(it) }
|
||||
?: SharedPdfTextAnnotationDefaults.displayFontSizeToPageRelative(fontSize)
|
||||
}
|
||||
|
||||
fun SharedPdfTextStyleConfig.sharedPdfTextFontSizePx(canvasSize: IntSize): Float {
|
||||
val pageHeightPx = canvasSize.height.coerceAtLeast(1).toFloat()
|
||||
return (sharedPdfTextPageRelativeFontSize() * pageHeightPx).coerceAtLeast(1f)
|
||||
}
|
||||
|
||||
fun SharedPdfAnnotation.sharedPdfTextPageRelativeFontSize(): Float {
|
||||
return pageRelativeFontSize
|
||||
?.let { SharedPdfTextAnnotationDefaults.legacyFontSizeToPageRelative(it) }
|
||||
?: SharedPdfTextAnnotationDefaults.displayFontSizeToPageRelative(fontSize)
|
||||
}
|
||||
|
||||
fun SharedPdfAnnotation.sharedPdfTextFontSizePx(canvasSize: IntSize): Float {
|
||||
val pageHeightPx = canvasSize.height.coerceAtLeast(1).toFloat()
|
||||
return (sharedPdfTextPageRelativeFontSize() * pageHeightPx).coerceAtLeast(1f)
|
||||
}
|
||||
|
||||
private fun PdfPageBounds.coercedToPage(): PdfPageBounds {
|
||||
val coercedLeft = left.coerceIn(0f, 1f)
|
||||
val coercedTop = top.coerceIn(0f, 1f)
|
||||
val coercedRight = right.coerceIn(coercedLeft, 1f)
|
||||
val coercedBottom = bottom.coerceIn(coercedTop, 1f)
|
||||
return PdfPageBounds(
|
||||
left = coercedLeft,
|
||||
top = coercedTop,
|
||||
right = coercedRight,
|
||||
bottom = coercedBottom
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,205 @@
|
|||
package org.dueattendant149.bookreader.shared.reader
|
||||
|
||||
import org.dueattendant149.bookreader.paginatedreader.SemanticBlock
|
||||
import org.dueattendant149.bookreader.paginatedreader.SemanticFlexContainer
|
||||
import org.dueattendant149.bookreader.paginatedreader.SemanticImage
|
||||
import org.dueattendant149.bookreader.paginatedreader.SemanticList
|
||||
import org.dueattendant149.bookreader.paginatedreader.SemanticTable
|
||||
import org.dueattendant149.bookreader.paginatedreader.SemanticTextBlock
|
||||
import org.dueattendant149.bookreader.paginatedreader.SemanticWrappingBlock
|
||||
import org.dueattendant149.bookreader.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)
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
package org.dueattendant149.bookreader.shared.reader
|
||||
|
||||
data class ReaderJumpHistory(
|
||||
val locators: List<ReaderLocator> = emptyList(),
|
||||
val cursor: Int = -1,
|
||||
val maxEntries: Int = 21
|
||||
) {
|
||||
val backLocator: ReaderLocator? get() = locators.getOrNull(cursor - 1)
|
||||
val forwardLocator: ReaderLocator? get() = locators.getOrNull(cursor + 1)
|
||||
val hasJumpTargets: Boolean get() = backLocator != null || forwardLocator != null
|
||||
|
||||
fun record(
|
||||
currentLocator: ReaderLocator?,
|
||||
targetLocator: ReaderLocator?,
|
||||
chapterCount: Int
|
||||
): ReaderJumpHistory {
|
||||
val current = currentLocator?.takeIf { it.isValidJumpLocator(chapterCount) } ?: return this
|
||||
val target = targetLocator?.takeIf { it.isValidJumpLocator(chapterCount) } ?: return this
|
||||
if (current.hasSameJumpLocation(target)) return this
|
||||
|
||||
val pruned = pruned(chapterCount)
|
||||
val nextLocators = pruned.locators.toMutableList()
|
||||
var nextCursor = pruned.cursor
|
||||
|
||||
while (nextLocators.lastIndex > nextCursor) {
|
||||
nextLocators.removeAt(nextLocators.lastIndex)
|
||||
}
|
||||
|
||||
if (nextCursor > 0 && nextLocators.getOrNull(nextCursor - 1)?.hasSameJumpLocation(current) == true) {
|
||||
nextLocators[nextCursor] = target
|
||||
return copy(
|
||||
locators = nextLocators,
|
||||
cursor = nextCursor
|
||||
).bounded()
|
||||
}
|
||||
|
||||
if (nextCursor == -1 || nextLocators.getOrNull(nextCursor)?.hasSameJumpLocation(current) != true) {
|
||||
nextLocators += current
|
||||
nextCursor = nextLocators.lastIndex
|
||||
}
|
||||
|
||||
if (nextLocators.lastOrNull()?.hasSameJumpLocation(target) != true) {
|
||||
nextLocators += target
|
||||
nextCursor = nextLocators.lastIndex
|
||||
}
|
||||
|
||||
return copy(
|
||||
locators = nextLocators,
|
||||
cursor = nextCursor
|
||||
).bounded()
|
||||
}
|
||||
|
||||
fun pruned(chapterCount: Int): ReaderJumpHistory {
|
||||
if (chapterCount <= 0) return clear()
|
||||
val nextLocators = locators.toMutableList()
|
||||
var nextCursor = cursor
|
||||
var index = nextLocators.lastIndex
|
||||
while (index >= 0) {
|
||||
if (!nextLocators[index].isValidJumpLocator(chapterCount)) {
|
||||
nextLocators.removeAt(index)
|
||||
if (nextCursor >= index) nextCursor--
|
||||
}
|
||||
index--
|
||||
}
|
||||
return copy(
|
||||
locators = nextLocators,
|
||||
cursor = nextCursor.coerceIn(-1, nextLocators.lastIndex)
|
||||
).bounded()
|
||||
}
|
||||
|
||||
fun stepBack(): ReaderJumpHistory {
|
||||
return if (backLocator == null) this else copy(cursor = (cursor - 1).coerceAtLeast(0))
|
||||
}
|
||||
|
||||
fun stepForward(): ReaderJumpHistory {
|
||||
return if (forwardLocator == null) this else copy(cursor = (cursor + 1).coerceAtMost(locators.lastIndex))
|
||||
}
|
||||
|
||||
fun clear(): ReaderJumpHistory = copy(locators = emptyList(), cursor = -1)
|
||||
|
||||
private fun bounded(): ReaderJumpHistory {
|
||||
val safeMaxEntries = maxEntries.coerceAtLeast(2)
|
||||
if (locators.size <= safeMaxEntries) {
|
||||
return copy(cursor = cursor.coerceIn(-1, locators.lastIndex))
|
||||
}
|
||||
val overflow = locators.size - safeMaxEntries
|
||||
return copy(
|
||||
locators = locators.drop(overflow),
|
||||
cursor = (cursor - overflow).coerceIn(-1, locators.size - overflow - 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun ReaderLocator.hasSameJumpLocation(other: ReaderLocator): Boolean {
|
||||
return jumpLocationKey() == other.jumpLocationKey()
|
||||
}
|
||||
|
||||
private fun ReaderLocator.isValidJumpLocator(chapterCount: Int): Boolean {
|
||||
if (chapterCount <= 0) return false
|
||||
val chapter = chapterIndex
|
||||
return chapter == null || chapter in 0 until chapterCount
|
||||
}
|
||||
|
||||
private fun ReaderLocator.jumpLocationKey(): String {
|
||||
val stableCfi = cfi?.takeIf { it.isNotBlank() }
|
||||
if (stableCfi != null) {
|
||||
return listOf(
|
||||
chapterIndex?.toString().orEmpty(),
|
||||
chapterId.orEmpty(),
|
||||
href.orEmpty(),
|
||||
startOffset?.toString().orEmpty(),
|
||||
endOffset?.toString().orEmpty(),
|
||||
blockIndex?.toString().orEmpty(),
|
||||
charOffset?.toString().orEmpty(),
|
||||
stableCfi
|
||||
).joinToString("|")
|
||||
}
|
||||
return listOf(
|
||||
chapterIndex?.toString().orEmpty(),
|
||||
chapterId.orEmpty(),
|
||||
href.orEmpty(),
|
||||
pageIndex?.toString().orEmpty(),
|
||||
startOffset?.toString().orEmpty(),
|
||||
endOffset?.toString().orEmpty(),
|
||||
blockIndex?.toString().orEmpty(),
|
||||
charOffset?.toString().orEmpty(),
|
||||
cfi.orEmpty()
|
||||
).joinToString("|")
|
||||
}
|
||||
|
|
@ -0,0 +1,264 @@
|
|||
package org.dueattendant149.bookreader.shared.reader
|
||||
|
||||
import org.dueattendant149.bookreader.paginatedreader.SemanticBlock
|
||||
import org.dueattendant149.bookreader.shared.PageInfoMode
|
||||
import org.dueattendant149.bookreader.shared.PageInfoPosition
|
||||
import org.dueattendant149.bookreader.shared.SystemUiMode
|
||||
|
||||
data class SharedEpubBook(
|
||||
val id: String,
|
||||
val fileName: String,
|
||||
val title: String,
|
||||
val author: String? = null,
|
||||
val chapters: List<SharedEpubChapter>,
|
||||
val css: Map<String, String> = emptyMap(),
|
||||
val tableOfContents: List<SharedEpubTocEntry> = emptyList()
|
||||
)
|
||||
|
||||
data class SharedEpubTocEntry(
|
||||
val label: String,
|
||||
val href: String,
|
||||
val fragmentId: String? = null,
|
||||
val depth: Int = 0
|
||||
)
|
||||
|
||||
data class SharedEpubChapter(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val plainText: String,
|
||||
val semanticBlocks: List<SemanticBlock> = emptyList(),
|
||||
val htmlContent: String = "",
|
||||
val baseHref: String? = null
|
||||
)
|
||||
|
||||
typealias ReaderLocator = org.dueattendant149.bookreader.shared.ReaderLocator
|
||||
|
||||
enum class ReaderReadingMode {
|
||||
PAGINATED,
|
||||
VERTICAL
|
||||
}
|
||||
|
||||
enum class ReaderPageSpreadMode {
|
||||
SINGLE,
|
||||
TWO_PAGE
|
||||
}
|
||||
|
||||
enum class SharedReaderTextAlign {
|
||||
START,
|
||||
RIGHT,
|
||||
JUSTIFY,
|
||||
CENTER
|
||||
}
|
||||
|
||||
data class ReaderSettings(
|
||||
val fontSize: Int = 18,
|
||||
val lineSpacing: Float = 1.45f,
|
||||
val margin: Int = 48,
|
||||
val darkMode: Boolean = false,
|
||||
val readingMode: ReaderReadingMode = ReaderReadingMode.VERTICAL,
|
||||
val textAlign: SharedReaderTextAlign = SharedReaderTextAlign.START,
|
||||
val pageWidth: Int = 760,
|
||||
val fontFamily: String = "Default",
|
||||
val paragraphSpacing: Float = 1.0f,
|
||||
val imageScale: Float = 1.0f,
|
||||
val horizontalMargin: Int? = null,
|
||||
val verticalMargin: Int? = null,
|
||||
val themeId: String? = null,
|
||||
val textureId: String? = null,
|
||||
val textureAlpha: Float = 0.55f,
|
||||
val customFontPath: String? = null,
|
||||
val backgroundColorArgb: Long? = null,
|
||||
val textColorArgb: Long? = null,
|
||||
val systemUiMode: SystemUiMode = SystemUiMode.DEFAULT,
|
||||
val pageInfoMode: PageInfoMode = PageInfoMode.DEFAULT,
|
||||
val pageInfoPosition: PageInfoPosition = PageInfoPosition.BOTTOM,
|
||||
val pageSpreadMode: ReaderPageSpreadMode = ReaderPageSpreadMode.SINGLE,
|
||||
val rightToLeftPagination: Boolean = false,
|
||||
val pdfVerticalPageGapVisible: Boolean = true,
|
||||
val pdfPageNumberOverlayVisible: Boolean = true,
|
||||
val pdfFirstPageStandaloneInSpread: Boolean = false,
|
||||
val seamlessChapterNavigation: Boolean = true,
|
||||
val chapterTurnDragMultiplier: Float = 1.0f
|
||||
) {
|
||||
val resolvedHorizontalMargin: Int get() = horizontalMargin ?: margin
|
||||
val resolvedVerticalMargin: Int get() = verticalMargin ?: margin
|
||||
}
|
||||
|
||||
data class ReaderLayoutSignature(
|
||||
val fontSize: Int,
|
||||
val lineSpacing: Float,
|
||||
val horizontalMargin: Int,
|
||||
val verticalMargin: Int,
|
||||
val readingMode: ReaderReadingMode,
|
||||
val textAlign: SharedReaderTextAlign,
|
||||
val pageWidth: Int,
|
||||
val fontFamily: String,
|
||||
val paragraphSpacing: Float,
|
||||
val imageScale: Float,
|
||||
val pageSpreadMode: ReaderPageSpreadMode,
|
||||
val customFontPath: String?
|
||||
)
|
||||
|
||||
data class ReaderAppearanceSignature(
|
||||
val darkMode: Boolean,
|
||||
val themeId: String?,
|
||||
val textureId: String?,
|
||||
val textureAlpha: Float,
|
||||
val backgroundColorArgb: Long?,
|
||||
val textColorArgb: Long?
|
||||
)
|
||||
|
||||
fun ReaderSettings.layoutSignature(): ReaderLayoutSignature {
|
||||
return ReaderLayoutSignature(
|
||||
fontSize = fontSize,
|
||||
lineSpacing = lineSpacing,
|
||||
horizontalMargin = resolvedHorizontalMargin,
|
||||
verticalMargin = resolvedVerticalMargin,
|
||||
readingMode = readingMode,
|
||||
textAlign = textAlign,
|
||||
pageWidth = pageWidth,
|
||||
fontFamily = fontFamily,
|
||||
paragraphSpacing = paragraphSpacing,
|
||||
imageScale = imageScale,
|
||||
pageSpreadMode = pageSpreadMode,
|
||||
customFontPath = customFontPath
|
||||
)
|
||||
}
|
||||
|
||||
fun ReaderSettings.appearanceSignature(): ReaderAppearanceSignature {
|
||||
return ReaderAppearanceSignature(
|
||||
darkMode = darkMode,
|
||||
themeId = themeId,
|
||||
textureId = textureId,
|
||||
textureAlpha = textureAlpha,
|
||||
backgroundColorArgb = backgroundColorArgb,
|
||||
textColorArgb = textColorArgb
|
||||
)
|
||||
}
|
||||
|
||||
data class ReaderViewportSpec(
|
||||
val widthPx: Int,
|
||||
val heightPx: Int
|
||||
) {
|
||||
val isSpecified: Boolean get() = widthPx > 0 && heightPx > 0
|
||||
}
|
||||
|
||||
data class ReaderPage(
|
||||
val pageIndex: Int,
|
||||
val chapterIndex: Int,
|
||||
val chapterTitle: String,
|
||||
val text: String,
|
||||
val startOffset: Int,
|
||||
val endOffset: Int,
|
||||
val semanticBlocks: List<SemanticBlock> = emptyList()
|
||||
)
|
||||
|
||||
data class PaginatedReaderState(
|
||||
val book: SharedEpubBook,
|
||||
val pages: List<ReaderPage>,
|
||||
val currentPageIndex: Int = 0,
|
||||
val settings: ReaderSettings = ReaderSettings()
|
||||
) {
|
||||
val currentPage: ReaderPage? get() = pages.getOrNull(currentPageIndex)
|
||||
val progress: Float
|
||||
get() {
|
||||
if (pages.isEmpty()) return 0f
|
||||
val visibleEnd = ReaderSpreadLayout.visiblePageIndices(currentPageIndex, pages.size, settings)
|
||||
.lastOrNull()
|
||||
?: currentPageIndex
|
||||
return ((visibleEnd + 1).toFloat() / pages.size) * 100f
|
||||
}
|
||||
val canGoPrevious: Boolean get() = currentPageIndex > 0
|
||||
val canGoNext: Boolean get() = ReaderSpreadLayout.canGoNext(currentPageIndex, pages.size, settings)
|
||||
val currentSpreadStartIndex: Int get() = ReaderSpreadLayout.normalizePageIndex(currentPageIndex, pages.size, settings)
|
||||
val visiblePages: List<ReaderPage>
|
||||
get() = ReaderSpreadLayout.visiblePageIndicesForDisplay(currentPageIndex, pages.size, settings)
|
||||
.mapNotNull { pages.getOrNull(it) }
|
||||
}
|
||||
|
||||
object ReaderSpreadLayout {
|
||||
fun pageStep(settings: ReaderSettings): Int {
|
||||
return if (settings.isTwoPageSpreadEnabled()) 2 else 1
|
||||
}
|
||||
|
||||
fun normalizePageIndex(pageIndex: Int, pageCount: Int, settings: ReaderSettings): Int {
|
||||
if (pageCount <= 0) return 0
|
||||
val clamped = pageIndex.coerceIn(0, pageCount - 1)
|
||||
return if (settings.isTwoPageSpreadEnabled()) {
|
||||
(clamped - (clamped % 2)).coerceIn(0, pageCount - 1)
|
||||
} else {
|
||||
clamped
|
||||
}
|
||||
}
|
||||
|
||||
fun canGoNext(pageIndex: Int, pageCount: Int, settings: ReaderSettings): Boolean {
|
||||
if (pageCount <= 1) return false
|
||||
val current = normalizePageIndex(pageIndex, pageCount, settings)
|
||||
return current + pageStep(settings) < pageCount
|
||||
}
|
||||
|
||||
fun nextPageIndex(pageIndex: Int, pageCount: Int, settings: ReaderSettings): Int {
|
||||
return normalizePageIndex(pageIndex + pageStep(settings), pageCount, settings)
|
||||
}
|
||||
|
||||
fun previousPageIndex(pageIndex: Int, pageCount: Int, settings: ReaderSettings): Int {
|
||||
return normalizePageIndex(pageIndex - pageStep(settings), pageCount, settings)
|
||||
}
|
||||
|
||||
fun visiblePageIndices(pageIndex: Int, pageCount: Int, settings: ReaderSettings): List<Int> {
|
||||
if (pageCount <= 0) return emptyList()
|
||||
val start = normalizePageIndex(pageIndex, pageCount, settings)
|
||||
if (!settings.isTwoPageSpreadEnabled()) return listOf(start)
|
||||
return listOf(start, start + 1).filter { it in 0 until pageCount }
|
||||
}
|
||||
|
||||
fun visiblePageIndicesForDisplay(pageIndex: Int, pageCount: Int, settings: ReaderSettings): List<Int> {
|
||||
val indices = visiblePageIndices(pageIndex, pageCount, settings)
|
||||
return if (settings.isRightToLeftPaginationEnabled()) indices.asReversed() else indices
|
||||
}
|
||||
|
||||
fun pageRangeLabel(pageIndex: Int, pageCount: Int, settings: ReaderSettings): String {
|
||||
val total = pageCount.coerceAtLeast(1)
|
||||
val pages = visiblePageIndices(pageIndex, total, settings).ifEmpty { listOf(0) }
|
||||
val first = pages.first() + 1
|
||||
val last = pages.last() + 1
|
||||
return if (first == last) "$first" else "$first-$last"
|
||||
}
|
||||
|
||||
fun sliderStepCount(pageCount: Int, settings: ReaderSettings): Int {
|
||||
val total = pageCount.coerceAtLeast(1)
|
||||
return if (settings.isTwoPageSpreadEnabled()) {
|
||||
(total + 1) / 2
|
||||
} else {
|
||||
total
|
||||
}
|
||||
}
|
||||
|
||||
fun sliderPositionForPage(pageIndex: Int, pageCount: Int, settings: ReaderSettings): Int {
|
||||
val normalized = normalizePageIndex(pageIndex, pageCount, settings)
|
||||
val position = if (settings.isTwoPageSpreadEnabled()) {
|
||||
(normalized / 2) + 1
|
||||
} else {
|
||||
normalized + 1
|
||||
}
|
||||
return position.coerceIn(1, sliderStepCount(pageCount, settings))
|
||||
}
|
||||
|
||||
fun pageNumberForSliderPosition(position: Int, pageCount: Int, settings: ReaderSettings): Int {
|
||||
val clamped = position.coerceIn(1, sliderStepCount(pageCount, settings))
|
||||
val pageIndex = if (settings.isTwoPageSpreadEnabled()) {
|
||||
(clamped - 1) * 2
|
||||
} else {
|
||||
clamped - 1
|
||||
}
|
||||
return normalizePageIndex(pageIndex, pageCount, settings) + 1
|
||||
}
|
||||
}
|
||||
|
||||
fun ReaderSettings.isTwoPageSpreadEnabled(): Boolean {
|
||||
return readingMode == ReaderReadingMode.PAGINATED && pageSpreadMode == ReaderPageSpreadMode.TWO_PAGE
|
||||
}
|
||||
|
||||
fun ReaderSettings.isRightToLeftPaginationEnabled(): Boolean {
|
||||
return readingMode == ReaderReadingMode.PAGINATED && rightToLeftPagination
|
||||
}
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
package org.dueattendant149.bookreader.shared.reader
|
||||
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.parser.Parser
|
||||
import org.jsoup.parser.Tag
|
||||
import java.io.File
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.util.zip.CRC32
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
data class SharedEpubMetadataUpdate(
|
||||
val title: String?,
|
||||
val author: String?,
|
||||
val description: String?,
|
||||
val seriesName: String?,
|
||||
val seriesIndex: Double?
|
||||
)
|
||||
|
||||
data class SharedEpubMetadataSnapshot(
|
||||
val title: String?,
|
||||
val author: String?,
|
||||
val description: String?,
|
||||
val seriesName: String?,
|
||||
val seriesIndex: Double?
|
||||
)
|
||||
|
||||
object SharedEpubMetadataEditor {
|
||||
fun readMetadata(source: File): SharedEpubMetadataSnapshot? {
|
||||
if (!source.isFile) return null
|
||||
return runCatching {
|
||||
ZipFile(source).use { zip ->
|
||||
val opfPath = findOpfPath(zip) ?: return null
|
||||
val opf = zip.getEntry(opfPath)?.let { zip.readUtf8(it) } ?: return null
|
||||
val metadata = parseOpfMetadata(opf) ?: return null
|
||||
metadata.toSnapshot()
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun rewrite(
|
||||
source: File,
|
||||
destination: File,
|
||||
update: SharedEpubMetadataUpdate
|
||||
): SharedEpubMetadataSnapshot {
|
||||
require(source.isFile) { "EPUB source does not exist: ${source.path}" }
|
||||
destination.parentFile?.mkdirs()
|
||||
|
||||
ZipFile(source).use { zip ->
|
||||
val opfPath = findOpfPath(zip) ?: error("EPUB package document was not found.")
|
||||
val opfEntry = zip.getEntry(opfPath) ?: error("EPUB package document entry is missing.")
|
||||
val originalOpf = zip.readUtf8(opfEntry)
|
||||
val updatedOpf = rewriteOpf(originalOpf, update)
|
||||
val updatedOpfBytes = updatedOpf.toByteArray(StandardCharsets.UTF_8)
|
||||
|
||||
if (destination.exists()) destination.delete()
|
||||
ZipOutputStream(destination.outputStream().buffered()).use { output ->
|
||||
val entries = zip.entries().asSequence().toList()
|
||||
entries.firstOrNull { it.name == "mimetype" }?.let { mimetype ->
|
||||
output.putStoredEntry(mimetype.name, zip.readBytes(mimetype), mimetype.time)
|
||||
}
|
||||
|
||||
entries.forEach { entry ->
|
||||
when {
|
||||
entry.name == "mimetype" -> Unit
|
||||
entry.name == opfPath -> output.putDeflatedEntry(entry.name, updatedOpfBytes, entry.time)
|
||||
entry.isDirectory -> output.putDirectoryEntry(entry)
|
||||
else -> output.putCopiedEntry(entry, zip.readBytes(entry))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return readMetadata(destination) ?: error("Rewritten EPUB failed metadata validation.")
|
||||
}
|
||||
|
||||
fun rewriteInPlace(
|
||||
source: File,
|
||||
backup: File?,
|
||||
update: SharedEpubMetadataUpdate
|
||||
): SharedEpubMetadataSnapshot {
|
||||
require(source.isFile) { "EPUB source does not exist: ${source.path}" }
|
||||
val temp = File(source.parentFile ?: source.absoluteFile.parentFile, "${source.name}.metadata.tmp")
|
||||
return try {
|
||||
val snapshot = rewrite(source, temp, update)
|
||||
if (backup != null && !backup.exists()) {
|
||||
backup.parentFile?.mkdirs()
|
||||
Files.copy(source.toPath(), backup.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||
}
|
||||
runCatching {
|
||||
Files.move(
|
||||
temp.toPath(),
|
||||
source.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE
|
||||
)
|
||||
}.getOrElse {
|
||||
Files.move(temp.toPath(), source.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||
}
|
||||
snapshot
|
||||
} finally {
|
||||
if (temp.exists()) temp.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rewriteOpf(opf: String, update: SharedEpubMetadataUpdate): String {
|
||||
val document = Jsoup.parse(opf, "", Parser.xmlParser())
|
||||
document.outputSettings()
|
||||
.syntax(Document.OutputSettings.Syntax.xml)
|
||||
.prettyPrint(false)
|
||||
.charset(StandardCharsets.UTF_8)
|
||||
|
||||
val packageElement = document.children().firstOrNull { it.localNameEquals("package") }
|
||||
packageElement?.let { ensureDcNamespace(it) }
|
||||
|
||||
val metadata = document.getAllElements().firstOrNull { it.localNameEquals("metadata") }
|
||||
?: error("EPUB package metadata section is missing.")
|
||||
|
||||
metadata.upsertDcText("title", update.title)
|
||||
metadata.upsertDcText("creator", update.author)
|
||||
metadata.upsertDcText("description", update.description)
|
||||
metadata.upsertMetaContent("calibre:series", update.seriesName)
|
||||
metadata.upsertMetaContent("calibre:series_index", update.seriesIndex?.formatSeriesIndex())
|
||||
|
||||
return document.outerHtml()
|
||||
}
|
||||
|
||||
private fun Element.toSnapshot(): SharedEpubMetadataSnapshot {
|
||||
return SharedEpubMetadataSnapshot(
|
||||
title = firstChildText("title"),
|
||||
author = firstChildText("creator"),
|
||||
description = firstChildText("description"),
|
||||
seriesName = firstMetaContent("calibre:series"),
|
||||
seriesIndex = firstMetaContent("calibre:series_index")?.toDoubleOrNull()
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseOpfMetadata(opf: String): Element? {
|
||||
val document = Jsoup.parse(opf, "", Parser.xmlParser())
|
||||
return document.getAllElements().firstOrNull { it.localNameEquals("metadata") }
|
||||
}
|
||||
|
||||
private fun findOpfPath(zip: ZipFile): String? {
|
||||
val container = zip.getEntry("META-INF/container.xml")
|
||||
?.let { zip.readUtf8(it) }
|
||||
val declared = container
|
||||
?.let { rootfilePathRegex.find(it)?.groupValues?.getOrNull(1) }
|
||||
?.trim()
|
||||
?.trimStart('/')
|
||||
?.takeIf { it.isNotBlank() }
|
||||
if (declared != null && zip.getEntry(declared) != null) return declared
|
||||
|
||||
return zip.entries()
|
||||
.asSequence()
|
||||
.map { it.name }
|
||||
.firstOrNull { it.endsWith(".opf", ignoreCase = true) }
|
||||
}
|
||||
|
||||
private val rootfilePathRegex = Regex(
|
||||
"""<rootfile\b[^>]*\bfull-path=["']([^"']+)["'][^>]*>""",
|
||||
RegexOption.IGNORE_CASE
|
||||
)
|
||||
|
||||
private fun ensureDcNamespace(packageElement: Element) {
|
||||
if (packageElement.attributes().asList().none { it.key.equals("xmlns:dc", ignoreCase = true) }) {
|
||||
packageElement.attr("xmlns:dc", "http://purl.org/dc/elements/1.1/")
|
||||
}
|
||||
}
|
||||
|
||||
private fun Element.upsertDcText(localName: String, value: String?) {
|
||||
val normalized = value?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val existing = children().filter { it.localNameEquals(localName) }
|
||||
if (normalized == null) {
|
||||
existing.forEach { it.remove() }
|
||||
return
|
||||
}
|
||||
val target = existing.firstOrNull() ?: Element(Tag.valueOf("dc:$localName"), "").also { appendChild(it) }
|
||||
target.text(normalized)
|
||||
existing.drop(1).forEach { it.remove() }
|
||||
}
|
||||
|
||||
private fun Element.upsertMetaContent(name: String, value: String?) {
|
||||
val normalized = value?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val existing = children().filter { child ->
|
||||
child.localNameEquals("meta") && child.attr("name").equals(name, ignoreCase = true)
|
||||
}
|
||||
if (normalized == null) {
|
||||
existing.forEach { it.remove() }
|
||||
return
|
||||
}
|
||||
val target = existing.firstOrNull() ?: Element(Tag.valueOf("meta"), "").also { appendChild(it) }
|
||||
target.attr("name", name)
|
||||
target.attr("content", normalized)
|
||||
existing.drop(1).forEach { it.remove() }
|
||||
}
|
||||
|
||||
private fun Element.firstChildText(localName: String): String? {
|
||||
return children()
|
||||
.firstOrNull { it.localNameEquals(localName) }
|
||||
?.text()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun Element.firstMetaContent(name: String): String? {
|
||||
return children()
|
||||
.firstOrNull { it.localNameEquals("meta") && it.attr("name").equals(name, ignoreCase = true) }
|
||||
?.attr("content")
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun Element.localNameEquals(expected: String): Boolean {
|
||||
return tagName().substringAfter(':').equals(expected, ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun Double.formatSeriesIndex(): String {
|
||||
return if (this % 1.0 == 0.0) {
|
||||
toInt().toString()
|
||||
} else {
|
||||
toString().trimEnd('0').trimEnd('.')
|
||||
}
|
||||
}
|
||||
|
||||
private fun ZipFile.readUtf8(entry: ZipEntry): String {
|
||||
return readBytes(entry).toString(StandardCharsets.UTF_8)
|
||||
}
|
||||
|
||||
private fun ZipFile.readBytes(entry: ZipEntry): ByteArray {
|
||||
return getInputStream(entry).use { it.readBytes() }
|
||||
}
|
||||
|
||||
private fun ZipOutputStream.putDirectoryEntry(entry: ZipEntry) {
|
||||
val copy = ZipEntry(entry.name)
|
||||
if (entry.time >= 0L) copy.time = entry.time
|
||||
copy.comment = entry.comment
|
||||
copy.extra = entry.extra
|
||||
putNextEntry(copy)
|
||||
closeEntry()
|
||||
}
|
||||
|
||||
private fun ZipOutputStream.putCopiedEntry(entry: ZipEntry, bytes: ByteArray) {
|
||||
if (entry.method == ZipEntry.STORED) {
|
||||
putStoredEntry(entry.name, bytes, entry.time)
|
||||
} else {
|
||||
putDeflatedEntry(entry.name, bytes, entry.time)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ZipOutputStream.putStoredEntry(name: String, bytes: ByteArray, time: Long) {
|
||||
val crc = CRC32().apply { update(bytes) }.value
|
||||
val entry = ZipEntry(name).apply {
|
||||
method = ZipEntry.STORED
|
||||
size = bytes.size.toLong()
|
||||
compressedSize = bytes.size.toLong()
|
||||
this.crc = crc
|
||||
if (time >= 0L) this.time = time
|
||||
}
|
||||
putNextEntry(entry)
|
||||
write(bytes)
|
||||
closeEntry()
|
||||
}
|
||||
|
||||
private fun ZipOutputStream.putDeflatedEntry(name: String, bytes: ByteArray, time: Long) {
|
||||
val entry = ZipEntry(name).apply {
|
||||
method = ZipEntry.DEFLATED
|
||||
if (time >= 0L) this.time = time
|
||||
}
|
||||
putNextEntry(entry)
|
||||
write(bytes)
|
||||
closeEntry()
|
||||
}
|
||||
|
|
@ -0,0 +1,597 @@
|
|||
@file:OptIn(ExperimentalSerializationApi::class)
|
||||
|
||||
package org.dueattendant149.bookreader.shared.reader
|
||||
|
||||
import org.dueattendant149.bookreader.paginatedreader.SemanticBlock
|
||||
import org.dueattendant149.bookreader.paginatedreader.semanticBlockModule
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromByteArray
|
||||
import kotlinx.serialization.encodeToByteArray
|
||||
import kotlinx.serialization.protobuf.ProtoBuf
|
||||
import kotlinx.serialization.protobuf.ProtoNumber
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import java.util.Locale
|
||||
|
||||
private const val SharedEpubPaginationCacheSchemaVersion = 1
|
||||
private const val SharedEpubPaginationProcessingVersion = 12
|
||||
private const val SharedEpubPaginationPageCacheVersion = 2
|
||||
|
||||
data class SharedEpubPaginationCacheKey(
|
||||
val bookHash: String,
|
||||
val bookFingerprint: Int,
|
||||
val configHash: Int,
|
||||
val chapterVersions: List<Int>
|
||||
) {
|
||||
val cacheId: String = "${bookHash}_${configHash.toUInt().toString(16)}"
|
||||
|
||||
fun chapterCacheId(chapterIndex: Int): String {
|
||||
return "${cacheId}_chapter_$chapterIndex"
|
||||
}
|
||||
}
|
||||
|
||||
class SharedEpubPaginationCache(
|
||||
private val cacheRoot: File = defaultCacheRoot()
|
||||
) {
|
||||
private val proto = ProtoBuf {
|
||||
serializersModule = semanticBlockModule
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private val memoryCache = SharedJvmLruMemoryCache<String, List<ReaderPage>>(maxEntries = 10)
|
||||
private val chapterMemoryCache = SharedJvmLruMemoryCache<String, List<ReaderPage>>(maxEntries = 24)
|
||||
|
||||
fun loadMemory(
|
||||
book: SharedEpubBook,
|
||||
settings: ReaderSettings,
|
||||
viewport: ReaderViewportSpec,
|
||||
density: Float = 1f,
|
||||
fontScale: Float = 1f
|
||||
): List<ReaderPage>? {
|
||||
val key = keyFor(book, settings, viewport, density, fontScale)
|
||||
return synchronized(memoryCache) {
|
||||
memoryCache[key.cacheId]
|
||||
}?.also { pages ->
|
||||
logEpubPaginationCache {
|
||||
"cache_lookup result=memory_hit book=\"${book.title.logPreview()}\" pages=${pages.size} " +
|
||||
"viewport=${viewport.widthPx}x${viewport.heightPx} config=${key.configHash.toUInt().toString(16)}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun load(
|
||||
book: SharedEpubBook,
|
||||
settings: ReaderSettings,
|
||||
viewport: ReaderViewportSpec,
|
||||
density: Float = 1f,
|
||||
fontScale: Float = 1f
|
||||
): List<ReaderPage>? = withContext(Dispatchers.IO) {
|
||||
val startedAt = System.nanoTime()
|
||||
val key = keyFor(book, settings, viewport, density, fontScale)
|
||||
synchronized(memoryCache) {
|
||||
memoryCache[key.cacheId]?.let { pages ->
|
||||
logEpubPaginationCache {
|
||||
"cache_lookup result=memory_hit book=\"${book.title.logPreview()}\" pages=${pages.size} " +
|
||||
"viewport=${viewport.widthPx}x${viewport.heightPx} config=${key.configHash.toUInt().toString(16)} " +
|
||||
"elapsedMs=${startedAt.elapsedMillis()}"
|
||||
}
|
||||
return@withContext pages
|
||||
}
|
||||
}
|
||||
|
||||
val file = cacheFile(key)
|
||||
if (!file.isFile) {
|
||||
logEpubPaginationCache {
|
||||
"cache_lookup result=miss reason=no_file book=\"${book.title.logPreview()}\" " +
|
||||
"viewport=${viewport.widthPx}x${viewport.heightPx} config=${key.configHash.toUInt().toString(16)} " +
|
||||
"path=\"${file.absolutePath.logPreview(220)}\" elapsedMs=${startedAt.elapsedMillis()}"
|
||||
}
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val record = proto.decodeFromByteArray<CachedReaderPages>(file.readBytes())
|
||||
if (!record.matches(key)) {
|
||||
logEpubPaginationCache {
|
||||
"cache_lookup result=miss reason=stale_record book=\"${book.title.logPreview()}\" " +
|
||||
"viewport=${viewport.widthPx}x${viewport.heightPx} config=${key.configHash.toUInt().toString(16)} " +
|
||||
"fileBytes=${file.length()} elapsedMs=${startedAt.elapsedMillis()}"
|
||||
}
|
||||
return@runCatching null
|
||||
}
|
||||
val pages = record.pages.mapIndexed { index, page -> page.toReaderPage(index) }
|
||||
if (pages.isEmpty() || pages.size != record.pageCount) {
|
||||
logEpubPaginationCache {
|
||||
"cache_lookup result=miss reason=page_count_mismatch book=\"${book.title.logPreview()}\" " +
|
||||
"storedPages=${record.pageCount} decodedPages=${pages.size} elapsedMs=${startedAt.elapsedMillis()}"
|
||||
}
|
||||
return@runCatching null
|
||||
}
|
||||
if (!pages.carriesRequiredSemanticBlocksFor(book)) {
|
||||
logEpubPaginationCache {
|
||||
"cache_lookup result=miss reason=missing_semantic_blocks book=\"${book.title.logPreview()}\" " +
|
||||
"pages=${pages.size} elapsedMs=${startedAt.elapsedMillis()}"
|
||||
}
|
||||
return@runCatching null
|
||||
}
|
||||
synchronized(memoryCache) {
|
||||
memoryCache[key.cacheId] = pages
|
||||
}
|
||||
logEpubPaginationCache {
|
||||
"cache_lookup result=disk_hit book=\"${book.title.logPreview()}\" pages=${pages.size} " +
|
||||
"viewport=${viewport.widthPx}x${viewport.heightPx} config=${key.configHash.toUInt().toString(16)} " +
|
||||
"fileBytes=${file.length()} elapsedMs=${startedAt.elapsedMillis()}"
|
||||
}
|
||||
pages
|
||||
}.getOrElse { error ->
|
||||
logEpubPaginationCache {
|
||||
"cache_lookup result=miss reason=decode_failed book=\"${book.title.logPreview()}\" " +
|
||||
"viewport=${viewport.widthPx}x${viewport.heightPx} config=${key.configHash.toUInt().toString(16)} " +
|
||||
"error=\"${error.message.orEmpty().logPreview(180)}\" elapsedMs=${startedAt.elapsedMillis()}"
|
||||
}
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadChapter(
|
||||
book: SharedEpubBook,
|
||||
settings: ReaderSettings,
|
||||
viewport: ReaderViewportSpec,
|
||||
chapterIndex: Int,
|
||||
density: Float = 1f,
|
||||
fontScale: Float = 1f
|
||||
): List<ReaderPage>? = withContext(Dispatchers.IO) {
|
||||
val startedAt = System.nanoTime()
|
||||
val key = keyFor(book, settings, viewport, density, fontScale)
|
||||
if (chapterIndex !in key.chapterVersions.indices) {
|
||||
logEpubPaginationCache {
|
||||
"chapter_cache_lookup result=miss reason=bad_chapter book=\"${book.title.logPreview()}\" " +
|
||||
"chapter=$chapterIndex viewport=${viewport.widthPx}x${viewport.heightPx}"
|
||||
}
|
||||
return@withContext null
|
||||
}
|
||||
val memoryKey = key.chapterCacheId(chapterIndex)
|
||||
synchronized(chapterMemoryCache) {
|
||||
chapterMemoryCache[memoryKey]?.let { pages ->
|
||||
logEpubPaginationCache {
|
||||
"chapter_cache_lookup result=memory_hit book=\"${book.title.logPreview()}\" chapter=$chapterIndex " +
|
||||
"pages=${pages.size} viewport=${viewport.widthPx}x${viewport.heightPx} " +
|
||||
"config=${key.configHash.toUInt().toString(16)} elapsedMs=${startedAt.elapsedMillis()}"
|
||||
}
|
||||
return@withContext pages
|
||||
}
|
||||
}
|
||||
|
||||
val file = chapterCacheFile(key, chapterIndex)
|
||||
if (!file.isFile) {
|
||||
logEpubPaginationCache {
|
||||
"chapter_cache_lookup result=miss reason=no_file book=\"${book.title.logPreview()}\" chapter=$chapterIndex " +
|
||||
"viewport=${viewport.widthPx}x${viewport.heightPx} config=${key.configHash.toUInt().toString(16)} " +
|
||||
"elapsedMs=${startedAt.elapsedMillis()}"
|
||||
}
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val record = proto.decodeFromByteArray<CachedReaderChapterPages>(file.readBytes())
|
||||
if (!record.matches(key, chapterIndex)) {
|
||||
logEpubPaginationCache {
|
||||
"chapter_cache_lookup result=miss reason=stale_record book=\"${book.title.logPreview()}\" " +
|
||||
"chapter=$chapterIndex viewport=${viewport.widthPx}x${viewport.heightPx} " +
|
||||
"fileBytes=${file.length()} elapsedMs=${startedAt.elapsedMillis()}"
|
||||
}
|
||||
return@runCatching null
|
||||
}
|
||||
val pages = record.pages.mapIndexed { index, page -> page.toReaderPage(record.firstPageIndex + index) }
|
||||
if (pages.isEmpty() || pages.size != record.pageCount) {
|
||||
logEpubPaginationCache {
|
||||
"chapter_cache_lookup result=miss reason=page_count_mismatch book=\"${book.title.logPreview()}\" " +
|
||||
"chapter=$chapterIndex storedPages=${record.pageCount} decodedPages=${pages.size} " +
|
||||
"elapsedMs=${startedAt.elapsedMillis()}"
|
||||
}
|
||||
return@runCatching null
|
||||
}
|
||||
if (!pages.carriesRequiredSemanticBlocksForChapter(book, chapterIndex)) {
|
||||
logEpubPaginationCache {
|
||||
"chapter_cache_lookup result=miss reason=missing_semantic_blocks book=\"${book.title.logPreview()}\" " +
|
||||
"chapter=$chapterIndex pages=${pages.size} elapsedMs=${startedAt.elapsedMillis()}"
|
||||
}
|
||||
return@runCatching null
|
||||
}
|
||||
synchronized(chapterMemoryCache) {
|
||||
chapterMemoryCache[memoryKey] = pages
|
||||
}
|
||||
logEpubPaginationCache {
|
||||
"chapter_cache_lookup result=disk_hit book=\"${book.title.logPreview()}\" chapter=$chapterIndex " +
|
||||
"pages=${pages.size} viewport=${viewport.widthPx}x${viewport.heightPx} " +
|
||||
"config=${key.configHash.toUInt().toString(16)} fileBytes=${file.length()} " +
|
||||
"elapsedMs=${startedAt.elapsedMillis()}"
|
||||
}
|
||||
pages
|
||||
}.getOrElse { error ->
|
||||
logEpubPaginationCache {
|
||||
"chapter_cache_lookup result=miss reason=decode_failed book=\"${book.title.logPreview()}\" " +
|
||||
"chapter=$chapterIndex viewport=${viewport.widthPx}x${viewport.heightPx} " +
|
||||
"error=\"${error.message.orEmpty().logPreview(180)}\" elapsedMs=${startedAt.elapsedMillis()}"
|
||||
}
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun save(
|
||||
book: SharedEpubBook,
|
||||
settings: ReaderSettings,
|
||||
viewport: ReaderViewportSpec,
|
||||
pages: List<ReaderPage>,
|
||||
density: Float = 1f,
|
||||
fontScale: Float = 1f
|
||||
): Unit = withContext(Dispatchers.IO) {
|
||||
if (pages.isEmpty()) {
|
||||
logEpubPaginationCache {
|
||||
"cache_save result=skip reason=empty_pages book=\"${book.title.logPreview()}\" " +
|
||||
"viewport=${viewport.widthPx}x${viewport.heightPx}"
|
||||
}
|
||||
return@withContext
|
||||
}
|
||||
if (!pages.carriesRequiredSemanticBlocksFor(book)) {
|
||||
logEpubPaginationCache {
|
||||
"cache_save result=skip reason=missing_semantic_blocks book=\"${book.title.logPreview()}\" " +
|
||||
"pages=${pages.size} viewport=${viewport.widthPx}x${viewport.heightPx}"
|
||||
}
|
||||
return@withContext
|
||||
}
|
||||
val key = keyFor(book, settings, viewport, density, fontScale)
|
||||
val record = CachedReaderPages(
|
||||
schemaVersion = SharedEpubPaginationCacheSchemaVersion,
|
||||
processingVersion = SharedEpubPaginationProcessingVersion,
|
||||
pageCacheVersion = SharedEpubPaginationPageCacheVersion,
|
||||
bookFingerprint = key.bookFingerprint,
|
||||
configHash = key.configHash,
|
||||
chapterVersions = key.chapterVersions,
|
||||
pageCount = pages.size,
|
||||
pages = pages.map(CachedReaderPage::from)
|
||||
)
|
||||
val file = cacheFile(key)
|
||||
runCatching {
|
||||
writeAtomically(file, proto.encodeToByteArray(record))
|
||||
synchronized(memoryCache) {
|
||||
memoryCache[key.cacheId] = pages.mapIndexed { index, page -> page.copy(pageIndex = index) }
|
||||
}
|
||||
saveChapterCaches(key, book, pages)
|
||||
cleanupOldConfigurations(key.bookHash)
|
||||
logEpubPaginationCache {
|
||||
"cache_save result=ok book=\"${book.title.logPreview()}\" pages=${pages.size} " +
|
||||
"viewport=${viewport.widthPx}x${viewport.heightPx} config=${key.configHash.toUInt().toString(16)} " +
|
||||
"fileBytes=${file.length()}"
|
||||
}
|
||||
}.onFailure { error ->
|
||||
logEpubPaginationCache {
|
||||
"cache_save result=failed book=\"${book.title.logPreview()}\" pages=${pages.size} " +
|
||||
"viewport=${viewport.widthPx}x${viewport.heightPx} config=${key.configHash.toUInt().toString(16)} " +
|
||||
"error=\"${error.message.orEmpty().logPreview(180)}\""
|
||||
}
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
fun keyFor(
|
||||
book: SharedEpubBook,
|
||||
settings: ReaderSettings,
|
||||
viewport: ReaderViewportSpec,
|
||||
density: Float = 1f,
|
||||
fontScale: Float = 1f
|
||||
): SharedEpubPaginationCacheKey {
|
||||
val chapterVersions = book.chapters.map(::chapterContentVersion)
|
||||
val bookFingerprint = bookFingerprint(book, chapterVersions)
|
||||
val configHash = stableHash(
|
||||
SharedEpubPaginationProcessingVersion,
|
||||
SharedEpubPaginationPageCacheVersion,
|
||||
viewport.widthPx,
|
||||
viewport.heightPx,
|
||||
density.roundCacheValue(),
|
||||
fontScale.roundCacheValue(),
|
||||
settings.fontSize,
|
||||
settings.lineSpacing.roundCacheValue(),
|
||||
settings.resolvedHorizontalMargin,
|
||||
settings.resolvedVerticalMargin,
|
||||
settings.readingMode.name,
|
||||
settings.textAlign.name,
|
||||
settings.pageWidth,
|
||||
settings.fontFamily,
|
||||
settings.paragraphSpacing.roundCacheValue(),
|
||||
settings.imageScale.roundCacheValue(),
|
||||
settings.pageSpreadMode.name,
|
||||
settings.customFontPath.orEmpty()
|
||||
)
|
||||
return SharedEpubPaginationCacheKey(
|
||||
bookHash = sha256Hex("${book.id}|${book.fileName}|$bookFingerprint").take(32),
|
||||
bookFingerprint = bookFingerprint,
|
||||
configHash = configHash,
|
||||
chapterVersions = chapterVersions
|
||||
)
|
||||
}
|
||||
|
||||
fun clearBook(book: SharedEpubBook) {
|
||||
val chapterVersions = book.chapters.map(::chapterContentVersion)
|
||||
val bookFingerprint = bookFingerprint(book, chapterVersions)
|
||||
val bookHash = sha256Hex("${book.id}|${book.fileName}|$bookFingerprint").take(32)
|
||||
File(cacheRoot, bookHash).deleteRecursively()
|
||||
synchronized(memoryCache) {
|
||||
memoryCache.clear()
|
||||
}
|
||||
synchronized(chapterMemoryCache) {
|
||||
chapterMemoryCache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAll() {
|
||||
cacheRoot.deleteRecursively()
|
||||
cacheRoot.mkdirs()
|
||||
synchronized(memoryCache) {
|
||||
memoryCache.clear()
|
||||
}
|
||||
synchronized(chapterMemoryCache) {
|
||||
chapterMemoryCache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cacheFile(key: SharedEpubPaginationCacheKey): File {
|
||||
return File(File(cacheRoot, key.bookHash), "${key.configHash.toUInt().toString(16)}.pages.pb")
|
||||
}
|
||||
|
||||
private fun chapterCacheDir(key: SharedEpubPaginationCacheKey): File {
|
||||
return File(File(cacheRoot, key.bookHash), "${key.configHash.toUInt().toString(16)}.chapters")
|
||||
}
|
||||
|
||||
private fun chapterCacheFile(key: SharedEpubPaginationCacheKey, chapterIndex: Int): File {
|
||||
return File(chapterCacheDir(key), "chapter_$chapterIndex.pages.pb")
|
||||
}
|
||||
|
||||
private fun saveChapterCaches(
|
||||
key: SharedEpubPaginationCacheKey,
|
||||
book: SharedEpubBook,
|
||||
pages: List<ReaderPage>
|
||||
) {
|
||||
pages.groupBy { it.chapterIndex }.forEach { (chapterIndex, chapterPages) ->
|
||||
if (chapterIndex !in key.chapterVersions.indices) return@forEach
|
||||
if (!chapterPages.carriesRequiredSemanticBlocksForChapter(book, chapterIndex)) return@forEach
|
||||
val firstPageIndex = chapterPages.minOfOrNull { it.pageIndex } ?: return@forEach
|
||||
val normalizedPages = chapterPages
|
||||
.sortedBy { it.pageIndex }
|
||||
.mapIndexed { index, page -> page.copy(pageIndex = firstPageIndex + index) }
|
||||
val record = CachedReaderChapterPages(
|
||||
schemaVersion = SharedEpubPaginationCacheSchemaVersion,
|
||||
processingVersion = SharedEpubPaginationProcessingVersion,
|
||||
pageCacheVersion = SharedEpubPaginationPageCacheVersion,
|
||||
bookFingerprint = key.bookFingerprint,
|
||||
configHash = key.configHash,
|
||||
chapterVersion = key.chapterVersions[chapterIndex],
|
||||
chapterIndex = chapterIndex,
|
||||
firstPageIndex = firstPageIndex,
|
||||
pageCount = normalizedPages.size,
|
||||
pages = normalizedPages.map(CachedReaderPage::from)
|
||||
)
|
||||
writeAtomically(chapterCacheFile(key, chapterIndex), proto.encodeToByteArray(record))
|
||||
synchronized(chapterMemoryCache) {
|
||||
chapterMemoryCache[key.chapterCacheId(chapterIndex)] = normalizedPages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanupOldConfigurations(bookHash: String) {
|
||||
val bookDir = File(cacheRoot, bookHash)
|
||||
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()
|
||||
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 &&
|
||||
pageCacheVersion == SharedEpubPaginationPageCacheVersion &&
|
||||
bookFingerprint == key.bookFingerprint &&
|
||||
configHash == key.configHash &&
|
||||
chapterVersions == key.chapterVersions
|
||||
}
|
||||
|
||||
private fun CachedReaderChapterPages.matches(
|
||||
key: SharedEpubPaginationCacheKey,
|
||||
expectedChapterIndex: Int
|
||||
): Boolean {
|
||||
return schemaVersion == SharedEpubPaginationCacheSchemaVersion &&
|
||||
processingVersion == SharedEpubPaginationProcessingVersion &&
|
||||
pageCacheVersion == SharedEpubPaginationPageCacheVersion &&
|
||||
bookFingerprint == key.bookFingerprint &&
|
||||
configHash == key.configHash &&
|
||||
chapterIndex == expectedChapterIndex &&
|
||||
expectedChapterIndex in key.chapterVersions.indices &&
|
||||
chapterVersion == key.chapterVersions[expectedChapterIndex]
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun defaultCacheRoot(): File {
|
||||
val overridePath = System.getProperty("reader.epub.pagination.cache.dir")
|
||||
if (!overridePath.isNullOrBlank()) return File(overridePath).apply { mkdirs() }
|
||||
return File(sharedJvmEpistemeCacheRoot(), "epub_page_cache").apply { mkdirs() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class CachedReaderPages(
|
||||
@ProtoNumber(1) val schemaVersion: Int,
|
||||
@ProtoNumber(2) val processingVersion: Int,
|
||||
@ProtoNumber(3) val pageCacheVersion: Int,
|
||||
@ProtoNumber(4) val bookFingerprint: Int,
|
||||
@ProtoNumber(5) val configHash: Int,
|
||||
@ProtoNumber(6) val chapterVersions: List<Int>,
|
||||
@ProtoNumber(7) val pageCount: Int,
|
||||
@ProtoNumber(8) val pages: List<CachedReaderPage>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class CachedReaderChapterPages(
|
||||
@ProtoNumber(1) val schemaVersion: Int,
|
||||
@ProtoNumber(2) val processingVersion: Int,
|
||||
@ProtoNumber(3) val pageCacheVersion: Int,
|
||||
@ProtoNumber(4) val bookFingerprint: Int,
|
||||
@ProtoNumber(5) val configHash: Int,
|
||||
@ProtoNumber(6) val chapterVersion: Int,
|
||||
@ProtoNumber(7) val chapterIndex: Int,
|
||||
@ProtoNumber(8) val firstPageIndex: Int,
|
||||
@ProtoNumber(9) val pageCount: Int,
|
||||
@ProtoNumber(10) val pages: List<CachedReaderPage>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class CachedReaderPage(
|
||||
@ProtoNumber(1) val chapterIndex: Int,
|
||||
@ProtoNumber(2) val chapterTitle: String,
|
||||
@ProtoNumber(3) val text: String,
|
||||
@ProtoNumber(4) val startOffset: Int,
|
||||
@ProtoNumber(5) val endOffset: Int,
|
||||
@ProtoNumber(6) val semanticBlocks: List<SemanticBlock>
|
||||
) {
|
||||
fun toReaderPage(pageIndex: Int): ReaderPage {
|
||||
return ReaderPage(
|
||||
pageIndex = pageIndex,
|
||||
chapterIndex = chapterIndex,
|
||||
chapterTitle = chapterTitle,
|
||||
text = text,
|
||||
startOffset = startOffset,
|
||||
endOffset = endOffset,
|
||||
semanticBlocks = semanticBlocks
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(page: ReaderPage): CachedReaderPage {
|
||||
return CachedReaderPage(
|
||||
chapterIndex = page.chapterIndex,
|
||||
chapterTitle = page.chapterTitle,
|
||||
text = page.text,
|
||||
startOffset = page.startOffset,
|
||||
endOffset = page.endOffset,
|
||||
semanticBlocks = page.semanticBlocks
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun sharedEpubChapterContentVersion(chapter: SharedEpubChapter): Int {
|
||||
return chapterContentVersion(chapter)
|
||||
}
|
||||
|
||||
private fun chapterContentVersion(chapter: SharedEpubChapter): Int {
|
||||
return stableHash(
|
||||
chapter.id,
|
||||
chapter.title,
|
||||
chapter.baseHref.orEmpty(),
|
||||
chapter.plainText.length,
|
||||
chapter.plainText.hashCode(),
|
||||
chapter.htmlContent.length,
|
||||
chapter.htmlContent.hashCode(),
|
||||
chapter.semanticBlocks.hashCode()
|
||||
)
|
||||
}
|
||||
|
||||
private fun bookFingerprint(book: SharedEpubBook, chapterVersions: List<Int>): Int {
|
||||
return stableHash(
|
||||
SharedEpubPaginationProcessingVersion,
|
||||
book.id,
|
||||
book.fileName,
|
||||
book.title,
|
||||
book.author.orEmpty(),
|
||||
book.css.hashCode(),
|
||||
chapterVersions.joinToString(",")
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<ReaderPage>.carriesRequiredSemanticBlocksFor(book: SharedEpubBook): Boolean {
|
||||
val semanticChapterIndexes = book.chapters
|
||||
.mapIndexedNotNull { index, chapter -> index.takeIf { chapter.semanticBlocks.isNotEmpty() } }
|
||||
if (semanticChapterIndexes.isEmpty()) return true
|
||||
return semanticChapterIndexes.all { chapterIndex ->
|
||||
any { page -> page.chapterIndex == chapterIndex && page.semanticBlocks.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<ReaderPage>.carriesRequiredSemanticBlocksForChapter(
|
||||
book: SharedEpubBook,
|
||||
chapterIndex: Int
|
||||
): Boolean {
|
||||
val chapter = book.chapters.getOrNull(chapterIndex) ?: return false
|
||||
if (chapter.semanticBlocks.isEmpty()) return true
|
||||
return any { page -> page.chapterIndex == chapterIndex && page.semanticBlocks.isNotEmpty() }
|
||||
}
|
||||
|
||||
internal fun stableHash(vararg parts: Any?): Int {
|
||||
return parts.joinToString(separator = "\u001F") { part ->
|
||||
when (part) {
|
||||
null -> "<null>"
|
||||
is Float -> part.roundCacheValue()
|
||||
is Double -> part.toFloat().roundCacheValue()
|
||||
else -> part.toString()
|
||||
}
|
||||
}.hashCode()
|
||||
}
|
||||
|
||||
internal fun sha256Hex(value: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8))
|
||||
return digest.joinToString("") { byte -> "%02x".format(Locale.US, byte.toInt() and 0xFF) }
|
||||
}
|
||||
|
||||
private fun Float.roundCacheValue(): String {
|
||||
return "%.4f".format(Locale.US, this)
|
||||
}
|
||||
|
||||
private fun writeAtomically(file: File, bytes: ByteArray) {
|
||||
file.parentFile?.mkdirs()
|
||||
val parent = file.parentFile ?: file.absoluteFile.parentFile ?: File(".")
|
||||
val temp = File(parent, "${file.name}.tmp")
|
||||
temp.writeBytes(bytes)
|
||||
if (file.exists() && !file.delete()) {
|
||||
temp.delete()
|
||||
return
|
||||
}
|
||||
if (!temp.renameTo(file)) {
|
||||
file.writeBytes(bytes)
|
||||
temp.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun logEpubPaginationCache(message: () -> String) {
|
||||
logSharedReaderDiagnostic("EpistemeEpubPagination", message)
|
||||
}
|
||||
|
||||
private fun Long.elapsedMillis(): Long {
|
||||
return ((System.nanoTime() - this) / 1_000_000L).coerceAtLeast(0L)
|
||||
}
|
||||
|
||||
private fun String.logPreview(maxLength: Int = 96): String {
|
||||
return replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
.let { if (it.length <= maxLength) it else it.take(maxLength) + "..." }
|
||||
.replace("\"", "\\\"")
|
||||
}
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
@file:OptIn(ExperimentalSerializationApi::class)
|
||||
|
||||
package org.dueattendant149.bookreader.shared.reader
|
||||
|
||||
import org.dueattendant149.bookreader.paginatedreader.SemanticBlock
|
||||
import org.dueattendant149.bookreader.paginatedreader.semanticBlockModule
|
||||
import org.dueattendant149.bookreader.shared.FileType
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromByteArray
|
||||
import kotlinx.serialization.encodeToByteArray
|
||||
import kotlinx.serialization.protobuf.ProtoBuf
|
||||
import kotlinx.serialization.protobuf.ProtoNumber
|
||||
import java.io.File
|
||||
|
||||
private const val SharedJvmBookLoadCacheSchemaVersion = 1
|
||||
private const val SharedJvmBookLoadCacheProcessingVersion = 8
|
||||
|
||||
enum class SharedJvmBookLoadSemanticMode {
|
||||
FULL,
|
||||
SKIP
|
||||
}
|
||||
|
||||
data class SharedJvmBookLoadCacheKey(
|
||||
val canonicalPath: String,
|
||||
val type: FileType,
|
||||
val length: Long,
|
||||
val lastModified: Long,
|
||||
val semanticMode: SharedJvmBookLoadSemanticMode = SharedJvmBookLoadSemanticMode.FULL,
|
||||
val htmlChapterRange: String? = null
|
||||
) {
|
||||
val cacheId: String = sha256Hex(
|
||||
"$SharedJvmBookLoadCacheProcessingVersion|${semanticMode.name}|${htmlChapterRange.orEmpty()}|$canonicalPath|${type.name}|$length|$lastModified"
|
||||
).take(32)
|
||||
}
|
||||
|
||||
class SharedJvmBookLoadCache(
|
||||
private val cacheRoot: File = defaultCacheRoot()
|
||||
) {
|
||||
private val proto = ProtoBuf {
|
||||
serializersModule = semanticBlockModule
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
fun load(key: SharedJvmBookLoadCacheKey): SharedEpubBook? {
|
||||
val file = cacheFile(key)
|
||||
if (!file.isFile) return null
|
||||
return runCatching {
|
||||
val record = proto.decodeFromByteArray<CachedSharedEpubBook>(file.readBytes())
|
||||
if (!record.matches(key)) return@runCatching null
|
||||
record.toBook().takeUnless {
|
||||
key.requiresCachedSemanticBlocks() && it.hasHtmlContentWithoutSemanticBlocks()
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun save(key: SharedJvmBookLoadCacheKey, book: SharedEpubBook) {
|
||||
if (key.requiresCachedSemanticBlocks() && book.hasHtmlContentWithoutSemanticBlocks()) return
|
||||
val record = CachedSharedEpubBook.from(key, book)
|
||||
runCatching {
|
||||
writeBookLoadCacheAtomically(cacheFile(key), proto.encodeToByteArray(record))
|
||||
cleanupOldEntries()
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
cacheRoot.deleteRecursively()
|
||||
cacheRoot.mkdirs()
|
||||
}
|
||||
|
||||
private fun cacheFile(key: SharedJvmBookLoadCacheKey): File {
|
||||
return File(cacheRoot, "${key.cacheId}.book.pb")
|
||||
}
|
||||
|
||||
private fun cleanupOldEntries() {
|
||||
val files = cacheRoot.listFiles { file -> file.isFile && file.name.endsWith(".book.pb") }
|
||||
?.sortedByDescending { it.lastModified() }
|
||||
.orEmpty()
|
||||
files.drop(80).forEach { it.delete() }
|
||||
}
|
||||
|
||||
private fun CachedSharedEpubBook.matches(key: SharedJvmBookLoadCacheKey): Boolean {
|
||||
return schemaVersion == SharedJvmBookLoadCacheSchemaVersion &&
|
||||
processingVersion == SharedJvmBookLoadCacheProcessingVersion &&
|
||||
canonicalPath == key.canonicalPath &&
|
||||
type == key.type.name &&
|
||||
length == key.length &&
|
||||
lastModified == key.lastModified &&
|
||||
semanticMode == key.semanticMode.name &&
|
||||
htmlChapterRange == key.htmlChapterRange
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun defaultCacheRoot(): File {
|
||||
val overridePath = System.getProperty("reader.book.load.cache.dir")
|
||||
if (!overridePath.isNullOrBlank()) return File(overridePath).apply { mkdirs() }
|
||||
return File(sharedJvmEpistemeCacheRoot(), "book_load_cache").apply { mkdirs() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class CachedSharedEpubBook(
|
||||
@ProtoNumber(1) val schemaVersion: Int,
|
||||
@ProtoNumber(2) val processingVersion: Int,
|
||||
@ProtoNumber(3) val canonicalPath: String,
|
||||
@ProtoNumber(4) val type: String,
|
||||
@ProtoNumber(5) val length: Long,
|
||||
@ProtoNumber(6) val lastModified: Long,
|
||||
@ProtoNumber(7) val id: String,
|
||||
@ProtoNumber(8) val fileName: String,
|
||||
@ProtoNumber(9) val title: String,
|
||||
@ProtoNumber(10) val author: String?,
|
||||
@ProtoNumber(11) val css: Map<String, String>,
|
||||
@ProtoNumber(12) val chapters: List<CachedSharedEpubChapter>,
|
||||
@ProtoNumber(13) val tableOfContents: List<CachedSharedEpubTocEntry> = emptyList(),
|
||||
@ProtoNumber(14) val semanticMode: String = SharedJvmBookLoadSemanticMode.FULL.name,
|
||||
@ProtoNumber(15) val htmlChapterRange: String? = null
|
||||
) {
|
||||
fun toBook(): SharedEpubBook {
|
||||
return SharedEpubBook(
|
||||
id = id,
|
||||
fileName = fileName,
|
||||
title = title,
|
||||
author = author,
|
||||
css = css,
|
||||
chapters = chapters.map { it.toChapter() },
|
||||
tableOfContents = tableOfContents.map { it.toEntry() }
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(key: SharedJvmBookLoadCacheKey, book: SharedEpubBook): CachedSharedEpubBook {
|
||||
return CachedSharedEpubBook(
|
||||
schemaVersion = SharedJvmBookLoadCacheSchemaVersion,
|
||||
processingVersion = SharedJvmBookLoadCacheProcessingVersion,
|
||||
canonicalPath = key.canonicalPath,
|
||||
type = key.type.name,
|
||||
length = key.length,
|
||||
lastModified = key.lastModified,
|
||||
id = book.id,
|
||||
fileName = book.fileName,
|
||||
title = book.title,
|
||||
author = book.author,
|
||||
css = book.css,
|
||||
chapters = book.chapters.map(CachedSharedEpubChapter::from),
|
||||
tableOfContents = book.tableOfContents.map(CachedSharedEpubTocEntry::from),
|
||||
semanticMode = key.semanticMode.name,
|
||||
htmlChapterRange = key.htmlChapterRange
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class CachedSharedEpubTocEntry(
|
||||
@ProtoNumber(1) val label: String,
|
||||
@ProtoNumber(2) val href: String,
|
||||
@ProtoNumber(3) val fragmentId: String?,
|
||||
@ProtoNumber(4) val depth: Int
|
||||
) {
|
||||
fun toEntry(): SharedEpubTocEntry {
|
||||
return SharedEpubTocEntry(
|
||||
label = label,
|
||||
href = href,
|
||||
fragmentId = fragmentId,
|
||||
depth = depth
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(entry: SharedEpubTocEntry): CachedSharedEpubTocEntry {
|
||||
return CachedSharedEpubTocEntry(
|
||||
label = entry.label,
|
||||
href = entry.href,
|
||||
fragmentId = entry.fragmentId,
|
||||
depth = entry.depth
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class CachedSharedEpubChapter(
|
||||
@ProtoNumber(1) val id: String,
|
||||
@ProtoNumber(2) val title: String,
|
||||
@ProtoNumber(3) val plainText: String,
|
||||
@ProtoNumber(4) val semanticBlocks: List<SemanticBlock>,
|
||||
@ProtoNumber(5) val htmlContent: String,
|
||||
@ProtoNumber(6) val baseHref: String?
|
||||
) {
|
||||
fun toChapter(): SharedEpubChapter {
|
||||
return SharedEpubChapter(
|
||||
id = id,
|
||||
title = title,
|
||||
plainText = plainText,
|
||||
semanticBlocks = semanticBlocks,
|
||||
htmlContent = htmlContent,
|
||||
baseHref = baseHref
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(chapter: SharedEpubChapter): CachedSharedEpubChapter {
|
||||
return CachedSharedEpubChapter(
|
||||
id = chapter.id,
|
||||
title = chapter.title,
|
||||
plainText = chapter.plainText,
|
||||
semanticBlocks = chapter.semanticBlocks,
|
||||
htmlContent = chapter.htmlContent,
|
||||
baseHref = chapter.baseHref
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SharedJvmBookLoadCacheKey.requiresCachedSemanticBlocks(): Boolean {
|
||||
if (semanticMode == SharedJvmBookLoadSemanticMode.SKIP) return false
|
||||
return type.requiresCachedSemanticBlocks()
|
||||
}
|
||||
|
||||
private fun FileType.requiresCachedSemanticBlocks(): Boolean {
|
||||
return when (this) {
|
||||
FileType.EPUB,
|
||||
FileType.MOBI,
|
||||
FileType.HTML,
|
||||
FileType.FB2,
|
||||
FileType.DOCX,
|
||||
FileType.ODT,
|
||||
FileType.FODT -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun SharedEpubBook.hasHtmlContentWithoutSemanticBlocks(): Boolean {
|
||||
return chapters.any { it.htmlContent.isNotBlank() } &&
|
||||
chapters.none { it.semanticBlocks.isNotEmpty() }
|
||||
}
|
||||
|
||||
private fun writeBookLoadCacheAtomically(file: File, bytes: ByteArray) {
|
||||
file.parentFile?.mkdirs()
|
||||
val parent = file.parentFile ?: file.absoluteFile.parentFile ?: File(".")
|
||||
val temp = File(parent, "${file.name}.tmp")
|
||||
temp.writeBytes(bytes)
|
||||
if (file.exists() && !file.delete()) {
|
||||
temp.delete()
|
||||
return
|
||||
}
|
||||
if (!temp.renameTo(file)) {
|
||||
file.writeBytes(bytes)
|
||||
temp.delete()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package org.dueattendant149.bookreader.shared.reader
|
||||
|
||||
internal class SharedJvmLruMemoryCache<K, V>(
|
||||
private val maxEntries: Int
|
||||
) {
|
||||
private val entries = LinkedHashMap<K, V>(maxEntries, 0.75f, true)
|
||||
|
||||
operator fun get(key: K): V? {
|
||||
return entries[key]
|
||||
}
|
||||
|
||||
operator fun set(key: K, value: V) {
|
||||
entries[key] = value
|
||||
trimToMaxEntries()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
entries.clear()
|
||||
}
|
||||
|
||||
private fun trimToMaxEntries() {
|
||||
while (entries.size > maxEntries) {
|
||||
val iterator = entries.entries.iterator()
|
||||
if (!iterator.hasNext()) return
|
||||
iterator.next()
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package org.dueattendant149.bookreader.shared.reader
|
||||
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
internal fun sharedJvmEpistemeCacheRoot(
|
||||
env: (String) -> String? = System::getenv,
|
||||
userHome: String = System.getProperty("user.home").orEmpty(),
|
||||
osName: String = System.getProperty("os.name").orEmpty()
|
||||
): File {
|
||||
val normalizedOs = osName.trim().lowercase(Locale.ROOT)
|
||||
return when {
|
||||
normalizedOs.startsWith("windows") -> {
|
||||
val baseDir = env("APPDATA")?.takeIf { it.isNotBlank() }
|
||||
?: File(userHome, "AppData/Roaming").absolutePath
|
||||
File(baseDir, "Episteme")
|
||||
}
|
||||
normalizedOs == "linux" || normalizedOs.contains("linux") -> {
|
||||
val baseDir = env("XDG_CACHE_HOME")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let(::File)
|
||||
?.takeIf { it.isAbsolute }
|
||||
?: File(userHome, ".cache")
|
||||
File(baseDir, "episteme")
|
||||
}
|
||||
normalizedOs.startsWith("mac") || normalizedOs.contains("darwin") -> {
|
||||
File(userHome, "Library/Caches/Episteme")
|
||||
}
|
||||
else -> File(userHome, ".episteme/cache")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,27 @@
|
|||
package org.dueattendant149.bookreader.shared.reader
|
||||
|
||||
import android.util.Log
|
||||
import org.dueattendant149.bookreader.BuildConfig
|
||||
|
||||
const val SharedReaderDiagnosticsProperty = "episteme.desktop.diagnostics"
|
||||
const val SharedReaderDiagnosticsTagsProperty = "episteme.desktop.diagnostics.tags"
|
||||
const val SharedEpubCutoffDiagnosticsTag = "EpistemeEpubCutoff"
|
||||
|
||||
val SharedReaderDiagnosticsEnabled: Boolean = BuildConfig.DEBUG
|
||||
|
||||
fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean {
|
||||
if (!BuildConfig.DEBUG) return false
|
||||
return tag == SharedEpubCutoffDiagnosticsTag ||
|
||||
runCatching { Log.isLoggable(tag, Log.DEBUG) }.getOrDefault(false)
|
||||
}
|
||||
|
||||
fun writeSharedReaderDiagnostic(tag: String, message: String) {
|
||||
if (!BuildConfig.DEBUG) return
|
||||
Log.d(tag, message)
|
||||
}
|
||||
|
||||
inline fun logSharedReaderDiagnostic(tag: String, message: () -> String) {
|
||||
if (SharedReaderDiagnosticsEnabled && isSharedReaderDiagnosticTagEnabled(tag)) {
|
||||
writeSharedReaderDiagnostic(tag, message())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package org.dueattendant149.bookreader.shared.reader
|
||||
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
|
||||
internal fun resolveSharedReaderTextAlign(
|
||||
cssTextAlign: TextAlign,
|
||||
fallbackTextAlign: TextAlign
|
||||
): TextAlign {
|
||||
return when {
|
||||
fallbackTextAlign.isExplicitSharedReaderTextAlign() -> fallbackTextAlign
|
||||
cssTextAlign == TextAlign.Unspecified -> fallbackTextAlign
|
||||
cssTextAlign == TextAlign.Justify -> TextAlign.Left
|
||||
else -> cssTextAlign
|
||||
}
|
||||
}
|
||||
|
||||
private fun TextAlign.isExplicitSharedReaderTextAlign(): Boolean {
|
||||
return this != TextAlign.Start &&
|
||||
this != TextAlign.Left &&
|
||||
this != TextAlign.Unspecified
|
||||
}
|
||||
|
||||
internal fun resolveSharedReaderFontFeatureSettings(
|
||||
existingSettings: String?,
|
||||
fontVariantNumeric: String?
|
||||
): String? {
|
||||
val existing = existingSettings
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val numericFeatures = fontVariantNumeric
|
||||
?.lowercase()
|
||||
?.split(Regex("\\s+"))
|
||||
?.mapNotNull { token ->
|
||||
when (token) {
|
||||
"lining-nums" -> "lnum"
|
||||
"oldstyle-nums" -> "onum"
|
||||
"proportional-nums" -> "pnum"
|
||||
"tabular-nums" -> "tnum"
|
||||
"diagonal-fractions" -> "frac"
|
||||
"stacked-fractions" -> "afrc"
|
||||
"ordinal" -> "ordn"
|
||||
"slashed-zero" -> "zero"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
?.distinct()
|
||||
?.map { feature -> "\"$feature\" on" }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.joinToString(", ")
|
||||
|
||||
return listOfNotNull(existing, numericFeatures)
|
||||
.joinToString(", ")
|
||||
.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
package org.dueattendant149.bookreader.shared.reader
|
||||
|
||||
object SharedTextBookFactory {
|
||||
fun fromPlainText(
|
||||
id: String,
|
||||
fileName: String,
|
||||
title: String,
|
||||
plainText: String,
|
||||
author: String? = null
|
||||
): SharedEpubBook {
|
||||
return SharedEpubBook(
|
||||
id = id,
|
||||
fileName = fileName,
|
||||
title = title,
|
||||
author = author,
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "chapter_0",
|
||||
title = title,
|
||||
plainText = plainText.ifBlank { "This document did not contain readable text." }
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun fromHtml(
|
||||
id: String,
|
||||
fileName: String,
|
||||
title: String,
|
||||
html: String,
|
||||
author: String? = null
|
||||
): SharedEpubBook {
|
||||
val sanitizedHtml = html.sanitizeReaderHtml()
|
||||
val body = sanitizedHtml.extractBodyOrSelf()
|
||||
return SharedEpubBook(
|
||||
id = id,
|
||||
fileName = fileName,
|
||||
title = title,
|
||||
author = author,
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "chapter_0",
|
||||
title = sanitizedHtml.tagText("h1")
|
||||
.ifBlank { sanitizedHtml.tagText("title") }
|
||||
.ifBlank { title },
|
||||
plainText = sanitizedHtml.htmlToText().ifBlank { title },
|
||||
htmlContent = body
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.extractBodyOrSelf(): String {
|
||||
return Regex("(?is)<body\\b[^>]*>(.*?)</body>")
|
||||
.find(this)
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
?.trim()
|
||||
?: this
|
||||
}
|
||||
|
||||
private fun String.tagText(tag: String): String {
|
||||
return Regex("<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)</(?:[^:>]+:)?$tag>", RegexOption.IGNORE_CASE)
|
||||
.find(this)
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
?.htmlToText()
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
private fun String.htmlToText(): String {
|
||||
return replace(Regex("(?is)<script.*?</script>"), "")
|
||||
.replace(Regex("(?is)<style.*?</style>"), "")
|
||||
.replace(Regex("(?i)<br\\s*/?>"), "\n")
|
||||
.replace(Regex("(?i)</p\\s*>"), "\n\n")
|
||||
.replace(Regex("(?i)</h[1-6]\\s*>"), "\n\n")
|
||||
.replace(Regex("<[^>]+>"), " ")
|
||||
.decodeEntities()
|
||||
.replace(Regex("[ \\t\\x0B\\f\\r]+"), " ")
|
||||
.replace(Regex(" *\\n *"), "\n")
|
||||
.replace(Regex("\\n{3,}"), "\n\n")
|
||||
.trim()
|
||||
}
|
||||
|
||||
private fun String.decodeEntities(): String {
|
||||
return replace(" ", " ")
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace(Regex("&#x([0-9a-fA-F]+);")) { match ->
|
||||
match.groupValues[1].toIntOrNull(16)?.toChar()?.toString().orEmpty()
|
||||
}
|
||||
.replace(Regex("&#(\\d+);")) { match ->
|
||||
match.groupValues[1].toIntOrNull()?.toChar()?.toString().orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.sanitizeReaderHtml(): String {
|
||||
return replace(Regex("(?is)<script\\b.*?</script>"), "")
|
||||
.replace(Regex("(?is)<object\\b.*?</object>"), "")
|
||||
.replace(Regex("(?is)<embed\\b[^>]*>"), "")
|
||||
.replace(Regex("""(?i)\s+on[a-z]+\s*=\s*(['"]).*?\1"""), "")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
package org.dueattendant149.bookreader.shared.reader
|
||||
|
||||
class SimplePaginator {
|
||||
fun paginate(
|
||||
book: SharedEpubBook,
|
||||
settings: ReaderSettings,
|
||||
viewportWidth: Int = 980,
|
||||
viewportHeight: Int = 720
|
||||
): List<ReaderPage> {
|
||||
val charsPerPage = estimateCharsPerPage(settings, viewportWidth, viewportHeight)
|
||||
return book.chapters.flatMapIndexed { chapterIndex, chapter ->
|
||||
paginateChapter(
|
||||
chapter = chapter,
|
||||
chapterIndex = chapterIndex,
|
||||
firstPageIndex = 0,
|
||||
charsPerPage = charsPerPage
|
||||
)
|
||||
}.mapIndexed { pageIndex, page -> page.copy(pageIndex = pageIndex) }
|
||||
}
|
||||
|
||||
fun repaginate(
|
||||
state: PaginatedReaderState,
|
||||
settings: ReaderSettings,
|
||||
viewportWidth: Int = 980,
|
||||
viewportHeight: Int = 720
|
||||
): PaginatedReaderState {
|
||||
val current = state.currentPage
|
||||
val pages = paginate(state.book, settings, viewportWidth, viewportHeight)
|
||||
val newIndex = if (current == null) {
|
||||
0
|
||||
} else {
|
||||
pages.indexOfFirst {
|
||||
it.chapterIndex == current.chapterIndex && it.startOffset <= current.startOffset && it.endOffset >= current.startOffset
|
||||
}.takeIf { it >= 0 } ?: 0
|
||||
}
|
||||
return state.copy(pages = pages, currentPageIndex = newIndex.coerceIn(0, pages.lastIndex.coerceAtLeast(0)), settings = settings)
|
||||
}
|
||||
|
||||
private fun paginateChapter(
|
||||
chapter: SharedEpubChapter,
|
||||
chapterIndex: Int,
|
||||
firstPageIndex: Int,
|
||||
charsPerPage: Int
|
||||
): List<ReaderPage> {
|
||||
val normalized = chapter.plainText
|
||||
.replace("\r\n", "\n")
|
||||
.replace(Regex("\\n{3,}"), "\n\n")
|
||||
.trim()
|
||||
|
||||
if (normalized.isBlank()) {
|
||||
return listOf(
|
||||
ReaderPage(
|
||||
pageIndex = firstPageIndex,
|
||||
chapterIndex = chapterIndex,
|
||||
chapterTitle = chapter.title,
|
||||
text = "",
|
||||
startOffset = 0,
|
||||
endOffset = 0
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val pages = mutableListOf<ReaderPage>()
|
||||
var start = 0
|
||||
while (start < normalized.length) {
|
||||
val rawEnd = (start + charsPerPage).coerceAtMost(normalized.length)
|
||||
val end = findPageBreak(normalized, start, rawEnd)
|
||||
pages.add(
|
||||
ReaderPage(
|
||||
pageIndex = firstPageIndex + pages.size,
|
||||
chapterIndex = chapterIndex,
|
||||
chapterTitle = chapter.title,
|
||||
text = normalized.substring(start, end).trim(),
|
||||
startOffset = start,
|
||||
endOffset = end
|
||||
)
|
||||
)
|
||||
start = end
|
||||
while (start < normalized.length && normalized[start].isWhitespace()) {
|
||||
start++
|
||||
}
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
private fun findPageBreak(text: String, start: Int, rawEnd: Int): Int {
|
||||
if (rawEnd >= text.length) return text.length
|
||||
val paragraphBreak = text.lastIndexOf("\n\n", rawEnd).takeIf { it > start + 300 }
|
||||
if (paragraphBreak != null) return paragraphBreak
|
||||
val sentenceBreak = text.lastIndexOfAny(charArrayOf('.', '!', '?'), rawEnd - 1).takeIf { it > start + 300 }
|
||||
if (sentenceBreak != null) return sentenceBreak + 1
|
||||
val wordBreak = text.lastIndexOf(' ', rawEnd - 1).takeIf { it > start + 120 }
|
||||
return wordBreak ?: rawEnd
|
||||
}
|
||||
|
||||
private fun estimateCharsPerPage(
|
||||
settings: ReaderSettings,
|
||||
viewportWidth: Int,
|
||||
viewportHeight: Int
|
||||
): Int {
|
||||
val usableWidth = (viewportWidth - settings.resolvedHorizontalMargin * 2).coerceAtLeast(360)
|
||||
val usableHeight = (viewportHeight - settings.resolvedVerticalMargin * 2).coerceAtLeast(360)
|
||||
val averageCharWidth = settings.fontSize * 0.55f
|
||||
val lineHeight = settings.fontSize * settings.lineSpacing
|
||||
val charsPerLine = (usableWidth / averageCharWidth).toInt().coerceAtLeast(35)
|
||||
val linesPerPage = (usableHeight / lineHeight).toInt().coerceAtLeast(12)
|
||||
return (charsPerLine * linesPerPage).coerceIn(900, 4_500)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package org.dueattendant149.bookreader.shared.ui
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
|
||||
@Composable
|
||||
internal fun LocalBookCoverImage(
|
||||
path: String,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier
|
||||
) {
|
||||
val bitmap = remember(path) {
|
||||
runCatching { BitmapFactory.decodeFile(path)?.asImageBitmap() }.getOrNull()
|
||||
}
|
||||
if (bitmap != null) {
|
||||
Image(
|
||||
bitmap = bitmap,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,376 @@
|
|||
package org.dueattendant149.bookreader.shared.ui
|
||||
|
||||
import org.dueattendant149.bookreader.shared.BookItem
|
||||
import org.dueattendant149.bookreader.shared.FileType
|
||||
import org.dueattendant149.bookreader.shared.LibraryFilters
|
||||
import org.dueattendant149.bookreader.shared.ReadStatusFilter
|
||||
import org.dueattendant149.bookreader.shared.ReaderPlatform
|
||||
import org.dueattendant149.bookreader.shared.SharedFeaturePolicy
|
||||
import org.dueattendant149.bookreader.shared.SharedFileCapabilities
|
||||
import org.dueattendant149.bookreader.shared.SharedReaderScreenState
|
||||
import org.dueattendant149.bookreader.shared.ShelfType
|
||||
import org.dueattendant149.bookreader.shared.isOpdsStream
|
||||
import org.dueattendant149.bookreader.shared.progressPercentValue
|
||||
import org.dueattendant149.bookreader.shared.toHomeScreenModel
|
||||
|
||||
enum class SharedAppToolAction {
|
||||
SETTINGS,
|
||||
IMPORT_FILES,
|
||||
IMPORT_FOLDER,
|
||||
SYNC,
|
||||
APP_THEME,
|
||||
PRO,
|
||||
AI_SETTINGS,
|
||||
CUSTOM_FONTS,
|
||||
HELP_FEEDBACK,
|
||||
SUPPORT,
|
||||
ABOUT,
|
||||
TABS_TOGGLE
|
||||
}
|
||||
|
||||
enum class SharedAppMoreGroup {
|
||||
LIBRARY,
|
||||
ACCOUNT,
|
||||
PREFERENCES,
|
||||
HELP
|
||||
}
|
||||
|
||||
data class SharedAppMoreSection(
|
||||
val group: SharedAppMoreGroup,
|
||||
val actions: List<SharedAppToolAction>
|
||||
)
|
||||
|
||||
data class SharedAppShellModel(
|
||||
val primaryTabs: List<SharedAppTab>,
|
||||
val primaryActions: List<SharedAppToolAction>,
|
||||
val selectedPrimaryTab: SharedAppTab,
|
||||
val toolActions: List<SharedAppToolAction>,
|
||||
val moreSections: List<SharedAppMoreSection>,
|
||||
val showPrimaryNavigation: Boolean
|
||||
)
|
||||
|
||||
data class SharedSidebarSyncToggleModel(
|
||||
val visible: Boolean,
|
||||
val enabled: Boolean,
|
||||
val checked: Boolean
|
||||
)
|
||||
|
||||
fun sharedAppShellModel(
|
||||
selectedTab: SharedAppTab,
|
||||
aiSettingsAvailable: Boolean,
|
||||
featurePolicy: SharedFeaturePolicy = SharedFeaturePolicy.Standard
|
||||
): SharedAppShellModel {
|
||||
val primaryTabs = buildList {
|
||||
add(SharedAppTab.LIBRARY)
|
||||
if (featurePolicy.opdsCatalogs) add(SharedAppTab.CATALOGS)
|
||||
if (featurePolicy.aiAndCloud) add(SharedAppTab.PRO)
|
||||
}
|
||||
val primaryActions = buildList {
|
||||
if (aiSettingsAvailable && featurePolicy.aiAndCloud && !featurePolicy.byokAi) {
|
||||
add(SharedAppToolAction.AI_SETTINGS)
|
||||
}
|
||||
}
|
||||
val selectedPrimaryTab = when (selectedTab) {
|
||||
SharedAppTab.SHELVES -> SharedAppTab.LIBRARY
|
||||
SharedAppTab.SETTINGS,
|
||||
SharedAppTab.CUSTOM_FONTS,
|
||||
SharedAppTab.SUPPORT,
|
||||
SharedAppTab.FEEDBACK,
|
||||
SharedAppTab.ABOUT -> SharedAppTab.LIBRARY
|
||||
else -> selectedTab
|
||||
}.takeIf { it in primaryTabs } ?: SharedAppTab.LIBRARY
|
||||
val toolActions = buildList {
|
||||
add(SharedAppToolAction.SETTINGS)
|
||||
add(SharedAppToolAction.APP_THEME)
|
||||
if (aiSettingsAvailable && featurePolicy.aiAndCloud) add(SharedAppToolAction.AI_SETTINGS)
|
||||
add(SharedAppToolAction.CUSTOM_FONTS)
|
||||
if (featurePolicy.projectLinks) {
|
||||
add(SharedAppToolAction.HELP_FEEDBACK)
|
||||
add(SharedAppToolAction.SUPPORT)
|
||||
}
|
||||
add(SharedAppToolAction.ABOUT)
|
||||
}
|
||||
return SharedAppShellModel(
|
||||
primaryTabs = primaryTabs,
|
||||
primaryActions = primaryActions,
|
||||
selectedPrimaryTab = selectedPrimaryTab,
|
||||
toolActions = toolActions,
|
||||
moreSections = sharedAppMoreSections(toolActions),
|
||||
showPrimaryNavigation = selectedTab != SharedAppTab.READER
|
||||
)
|
||||
}
|
||||
|
||||
fun sharedAppMoreSections(actions: List<SharedAppToolAction>): List<SharedAppMoreSection> {
|
||||
return listOf(
|
||||
SharedAppMoreSection(
|
||||
group = SharedAppMoreGroup.PREFERENCES,
|
||||
actions = actions.filter {
|
||||
it == SharedAppToolAction.SETTINGS ||
|
||||
it == SharedAppToolAction.APP_THEME ||
|
||||
it == SharedAppToolAction.AI_SETTINGS ||
|
||||
it == SharedAppToolAction.CUSTOM_FONTS
|
||||
}
|
||||
),
|
||||
SharedAppMoreSection(
|
||||
group = SharedAppMoreGroup.HELP,
|
||||
actions = actions.filter {
|
||||
it == SharedAppToolAction.HELP_FEEDBACK ||
|
||||
it == SharedAppToolAction.SUPPORT ||
|
||||
it == SharedAppToolAction.ABOUT
|
||||
}
|
||||
)
|
||||
).filter { it.actions.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun sharedSidebarSyncToggleModel(
|
||||
isSignedIn: Boolean,
|
||||
accountAvailable: Boolean,
|
||||
syncAvailable: Boolean,
|
||||
isProUser: Boolean,
|
||||
isSyncEnabled: Boolean,
|
||||
featurePolicy: SharedFeaturePolicy = SharedFeaturePolicy.Standard
|
||||
): SharedSidebarSyncToggleModel {
|
||||
val visible = isSignedIn &&
|
||||
accountAvailable &&
|
||||
syncAvailable &&
|
||||
featurePolicy.aiAndCloud
|
||||
return SharedSidebarSyncToggleModel(
|
||||
visible = visible,
|
||||
enabled = visible && isProUser,
|
||||
checked = visible && isSyncEnabled
|
||||
)
|
||||
}
|
||||
|
||||
data class NonReaderHomeLayoutModel(
|
||||
val continueBook: BookItem?,
|
||||
val activeTabs: List<BookItem>,
|
||||
val pinnedBooks: List<BookItem>,
|
||||
val recentBooks: List<BookItem>,
|
||||
val selectedBooks: List<BookItem>,
|
||||
val isContextualModeActive: Boolean,
|
||||
val isEmpty: Boolean,
|
||||
val isLibraryEmpty: Boolean
|
||||
)
|
||||
|
||||
fun SharedReaderScreenState.toNonReaderHomeLayoutModel(): NonReaderHomeLayoutModel {
|
||||
val model = toHomeScreenModel()
|
||||
val activeTabs = if (isTabsEnabled) model.openTabs else emptyList()
|
||||
val continueBook = activeTabs.firstOrNull { it.id == activeTabBookId }
|
||||
?: model.recentBooks.firstOrNull { progressPercentValue(it.progressPercentage) in 1..99 }
|
||||
?: model.recentBooks.firstOrNull()
|
||||
val continueId = continueBook?.id
|
||||
val pinnedBooks = model.recentBooks
|
||||
.filter { it.id in pinnedHomeBookIds && it.id != continueId }
|
||||
val recentBooks = model.recentBooks
|
||||
.filter { it.id !in pinnedHomeBookIds && it.id != continueId }
|
||||
return NonReaderHomeLayoutModel(
|
||||
continueBook = continueBook,
|
||||
activeTabs = activeTabs,
|
||||
pinnedBooks = pinnedBooks,
|
||||
recentBooks = recentBooks,
|
||||
selectedBooks = model.selectedBooks,
|
||||
isContextualModeActive = model.isContextualModeActive,
|
||||
isEmpty = continueBook == null && pinnedBooks.isEmpty() && recentBooks.isEmpty() && activeTabs.isEmpty(),
|
||||
isLibraryEmpty = model.isLibraryEmpty
|
||||
)
|
||||
}
|
||||
|
||||
data class NonReaderLibraryOrganizationModel(
|
||||
val allBooksCount: Int,
|
||||
val shelfCount: Int,
|
||||
val smartShelfCount: Int,
|
||||
val tagCount: Int,
|
||||
val folderCount: Int,
|
||||
val unreadCount: Int,
|
||||
val inProgressCount: Int,
|
||||
val completedCount: Int,
|
||||
val activeFilterCount: Int,
|
||||
val availableFileTypes: List<FileType>,
|
||||
val hasInAppBooks: Boolean,
|
||||
val hasOpdsStreams: Boolean
|
||||
)
|
||||
|
||||
internal data class NonReaderLibraryFileTypeGroup(
|
||||
val titleKey: String,
|
||||
val titleFallback: String,
|
||||
val fileTypes: List<FileType>
|
||||
)
|
||||
|
||||
private val LibraryFileTypeGroupTemplates = listOf(
|
||||
NonReaderLibraryFileTypeGroup(
|
||||
titleKey = "desktop_file_type_group_books",
|
||||
titleFallback = "Books",
|
||||
fileTypes = listOf(FileType.EPUB, FileType.MOBI, FileType.FB2)
|
||||
),
|
||||
NonReaderLibraryFileTypeGroup(
|
||||
titleKey = "desktop_file_type_group_documents",
|
||||
titleFallback = "Documents",
|
||||
fileTypes = listOf(FileType.PDF, FileType.PPTX, FileType.DOCX, FileType.ODT, FileType.FODT)
|
||||
),
|
||||
NonReaderLibraryFileTypeGroup(
|
||||
titleKey = "desktop_file_type_group_text_web",
|
||||
titleFallback = "Text and web",
|
||||
fileTypes = listOf(FileType.MD, FileType.TXT, FileType.HTML)
|
||||
),
|
||||
NonReaderLibraryFileTypeGroup(
|
||||
titleKey = "desktop_file_type_group_comics",
|
||||
titleFallback = "Comics",
|
||||
fileTypes = listOf(FileType.CBZ, FileType.CBR, FileType.CB7, FileType.CBT)
|
||||
)
|
||||
)
|
||||
|
||||
private val AndroidLibraryTabs = listOf(
|
||||
NonReaderLibraryTab.BOOKS,
|
||||
NonReaderLibraryTab.SHELVES,
|
||||
NonReaderLibraryTab.FOLDERS
|
||||
)
|
||||
|
||||
private val DesktopLibraryTabs = listOf(
|
||||
NonReaderLibraryTab.BOOKS,
|
||||
NonReaderLibraryTab.SHELVES,
|
||||
NonReaderLibraryTab.FOLDERS,
|
||||
NonReaderLibraryTab.UNREAD,
|
||||
NonReaderLibraryTab.IN_PROGRESS,
|
||||
NonReaderLibraryTab.COMPLETED
|
||||
)
|
||||
|
||||
internal enum class NonReaderLibraryPrimaryAction {
|
||||
NEW_SHELF
|
||||
}
|
||||
|
||||
internal enum class NonReaderBookOverflowAction {
|
||||
ADD_TO_SHELF,
|
||||
SAVE_ORIGINAL,
|
||||
SHARE_ORIGINAL
|
||||
}
|
||||
|
||||
internal fun visibleNonReaderLibraryTabs(
|
||||
platform: ReaderPlatform = ReaderPlatform.ANDROID
|
||||
): List<NonReaderLibraryTab> {
|
||||
return when (platform) {
|
||||
ReaderPlatform.ANDROID -> AndroidLibraryTabs
|
||||
ReaderPlatform.DESKTOP -> DesktopLibraryTabs
|
||||
}
|
||||
}
|
||||
|
||||
internal fun primaryLibraryActionsForTab(
|
||||
tab: NonReaderLibraryTab,
|
||||
platform: ReaderPlatform = ReaderPlatform.ANDROID
|
||||
): List<NonReaderLibraryPrimaryAction> {
|
||||
return if (platform == ReaderPlatform.DESKTOP && tab.visibleLibraryTab(platform) == NonReaderLibraryTab.SHELVES) {
|
||||
listOf(NonReaderLibraryPrimaryAction.NEW_SHELF)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun bookOverflowActionsForPlatform(
|
||||
platform: ReaderPlatform = ReaderPlatform.ANDROID
|
||||
): Set<NonReaderBookOverflowAction> {
|
||||
return when (platform) {
|
||||
ReaderPlatform.DESKTOP -> setOf(
|
||||
NonReaderBookOverflowAction.ADD_TO_SHELF,
|
||||
NonReaderBookOverflowAction.SAVE_ORIGINAL
|
||||
)
|
||||
ReaderPlatform.ANDROID -> setOf(
|
||||
NonReaderBookOverflowAction.SAVE_ORIGINAL,
|
||||
NonReaderBookOverflowAction.SHARE_ORIGINAL
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal enum class LibraryCommandBarLayout {
|
||||
INLINE,
|
||||
STACKED
|
||||
}
|
||||
|
||||
internal fun libraryCommandBarLayoutForWidth(
|
||||
widthDp: Float,
|
||||
platform: ReaderPlatform = ReaderPlatform.DESKTOP
|
||||
): LibraryCommandBarLayout {
|
||||
return if (platform == ReaderPlatform.DESKTOP && widthDp >= 980f) {
|
||||
LibraryCommandBarLayout.INLINE
|
||||
} else {
|
||||
LibraryCommandBarLayout.STACKED
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
val readableTypes = SharedFileCapabilities.readableTypesFor(platform)
|
||||
val knownGroupedTypes = LibraryFileTypeGroupTemplates.flatMapTo(mutableSetOf()) { it.fileTypes }
|
||||
val grouped = LibraryFileTypeGroupTemplates.mapNotNull { group ->
|
||||
val visibleTypes = group.fileTypes.filter { it in readableTypes }
|
||||
group.copy(fileTypes = visibleTypes).takeIf { visibleTypes.isNotEmpty() }
|
||||
}
|
||||
val otherTypes = readableTypes
|
||||
.filterNot { it in knownGroupedTypes }
|
||||
.sortedBy { it.ordinal }
|
||||
|
||||
return if (otherTypes.isEmpty()) {
|
||||
grouped
|
||||
} else {
|
||||
grouped + NonReaderLibraryFileTypeGroup(
|
||||
titleKey = "desktop_file_type_group_other",
|
||||
titleFallback = "Other",
|
||||
fileTypes = otherTypes
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun SharedReaderScreenState.toNonReaderLibraryOrganizationModel(): NonReaderLibraryOrganizationModel {
|
||||
val books = organizationBooks()
|
||||
val rootFolderCount = shelves.count { it.type == ShelfType.FOLDER && it.parentShelfId == null }
|
||||
val tagIds = (allTags.map { it.id } + books.flatMap { book -> book.tags.map { it.id } }).toSet()
|
||||
return NonReaderLibraryOrganizationModel(
|
||||
allBooksCount = books.size,
|
||||
shelfCount = shelves.count { it.type != ShelfType.FOLDER && it.type != ShelfType.TAG && it.type != ShelfType.SMART },
|
||||
smartShelfCount = shelves.count { it.type == ShelfType.SMART },
|
||||
tagCount = tagIds.size,
|
||||
folderCount = maxOf(rootFolderCount, syncedFolders.size),
|
||||
unreadCount = books.count { progressPercentValue(it.progressPercentage) == 0 },
|
||||
inProgressCount = books.count { progressPercentValue(it.progressPercentage) in 1..99 },
|
||||
completedCount = books.count { progressPercentValue(it.progressPercentage) >= 100 },
|
||||
activeFilterCount = libraryFilters.activeFilterCount(),
|
||||
availableFileTypes = books
|
||||
.map { it.type }
|
||||
.filterNot { it == FileType.UNKNOWN }
|
||||
.distinct()
|
||||
.sortedBy { it.ordinal },
|
||||
hasInAppBooks = books.any { it.sourceFolder == null && !it.isOpdsStream() },
|
||||
hasOpdsStreams = books.any { it.isOpdsStream() }
|
||||
)
|
||||
}
|
||||
|
||||
private fun SharedReaderScreenState.organizationBooks(): List<BookItem> {
|
||||
if (shelves.isEmpty()) return rawLibraryBooks
|
||||
return shelves
|
||||
.flatMap { it.books }
|
||||
.distinctBy { it.id }
|
||||
}
|
||||
|
||||
private fun LibraryFilters.activeFilterCount(): Int {
|
||||
return fileTypes.size +
|
||||
sourceFolders.size +
|
||||
tagIds.size +
|
||||
if (readStatus == ReadStatusFilter.ALL) 0 else 1
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,62 @@
|
|||
package org.dueattendant149.bookreader.shared.ui
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import org.dueattendant149.bookreader.shared.ReaderHighlightPalette
|
||||
import org.dueattendant149.bookreader.shared.ReaderLocator
|
||||
import org.dueattendant149.bookreader.shared.UserHighlight
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderPage
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderSearchOptions
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderSettings
|
||||
import org.dueattendant149.bookreader.shared.reader.SharedEpubBook
|
||||
|
||||
data class ReaderContentNavigationTarget(
|
||||
val locator: ReaderLocator?,
|
||||
val requestId: Long,
|
||||
val readingMode: ReaderReadingMode,
|
||||
val ttsLocator: ReaderLocator? = null,
|
||||
val ttsRequestId: Long = 0L
|
||||
)
|
||||
|
||||
sealed interface ReaderContentRenderPlan {
|
||||
val background: Color
|
||||
val foreground: Color
|
||||
val navigationTarget: ReaderContentNavigationTarget
|
||||
val highlights: List<UserHighlight>
|
||||
|
||||
data class WebDocument(
|
||||
val html: String,
|
||||
val appearanceScript: String,
|
||||
val highlightPaletteScript: String,
|
||||
override val background: Color,
|
||||
override val foreground: Color,
|
||||
override val navigationTarget: ReaderContentNavigationTarget,
|
||||
override val highlights: List<UserHighlight>
|
||||
) : ReaderContentRenderPlan
|
||||
|
||||
data class NativePaginatedPages(
|
||||
val visiblePages: List<ReaderPage>,
|
||||
val settings: ReaderSettings,
|
||||
val searchQuery: String,
|
||||
val searchOptions: ReaderSearchOptions,
|
||||
val highlightPalette: ReaderHighlightPalette,
|
||||
override val background: Color,
|
||||
override val foreground: Color,
|
||||
override val navigationTarget: ReaderContentNavigationTarget,
|
||||
override val highlights: List<UserHighlight>
|
||||
) : ReaderContentRenderPlan
|
||||
|
||||
data class NativeVerticalPages(
|
||||
val book: SharedEpubBook,
|
||||
val pages: List<ReaderPage>,
|
||||
val currentPageIndex: Int,
|
||||
val settings: ReaderSettings,
|
||||
val searchQuery: String,
|
||||
val searchOptions: ReaderSearchOptions,
|
||||
val highlightPalette: ReaderHighlightPalette,
|
||||
override val background: Color,
|
||||
override val foreground: Color,
|
||||
override val navigationTarget: ReaderContentNavigationTarget,
|
||||
override val highlights: List<UserHighlight>
|
||||
) : ReaderContentRenderPlan
|
||||
}
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
package org.dueattendant149.bookreader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun ReaderMinimalSlider(
|
||||
value: Float,
|
||||
onValueChange: (Float) -> Unit,
|
||||
valueRange: ClosedFloatingPointRange<Float>,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
onValueChangeStarted: (() -> Unit)? = null,
|
||||
onValueChangeFinished: (() -> Unit)? = null,
|
||||
activeColor: Color? = null,
|
||||
inactiveColor: Color? = null,
|
||||
thumbColor: Color? = null,
|
||||
markerValue: Float? = null,
|
||||
markerColor: Color? = null
|
||||
) {
|
||||
var widthPx by remember { mutableFloatStateOf(0f) }
|
||||
var dragValue by remember { mutableStateOf<Float?>(null) }
|
||||
val rangeStart = valueRange.start
|
||||
val rangeEnd = valueRange.endInclusive
|
||||
|
||||
fun valueForOffset(offsetX: Float): Float {
|
||||
if (widthPx <= 0f || rangeEnd <= rangeStart) return value.coerceIn(rangeStart, rangeEnd)
|
||||
val fraction = (offsetX / widthPx).coerceIn(0f, 1f)
|
||||
return rangeStart + (rangeEnd - rangeStart) * fraction
|
||||
}
|
||||
|
||||
val inputModifier = if (enabled) {
|
||||
Modifier.pointerInput(rangeStart, rangeEnd) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
var gestureStarted = false
|
||||
|
||||
fun updateValue(offsetX: Float) {
|
||||
val nextValue = valueForOffset(offsetX)
|
||||
dragValue = nextValue
|
||||
onValueChange(nextValue)
|
||||
}
|
||||
|
||||
try {
|
||||
onValueChangeStarted?.invoke()
|
||||
gestureStarted = true
|
||||
updateValue(down.position.x)
|
||||
down.consume()
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
if (change == null || !change.pressed) break
|
||||
updateValue(change.position.x)
|
||||
change.consume()
|
||||
}
|
||||
} finally {
|
||||
if (gestureStarted) {
|
||||
onValueChangeFinished?.invoke()
|
||||
}
|
||||
dragValue = null
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
val effectiveActiveColor = activeColor ?: MaterialTheme.colorScheme.primary
|
||||
val effectiveInactiveColor = inactiveColor ?: MaterialTheme.colorScheme.surfaceVariant
|
||||
val effectiveThumbColor = thumbColor ?: MaterialTheme.colorScheme.primary
|
||||
val effectiveMarkerColor = markerColor ?: effectiveActiveColor
|
||||
val markerFraction = readerMinimalSliderMarkerFraction(markerValue, valueRange)
|
||||
val disabledAlpha = if (enabled) 1f else 0.38f
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(24.dp)
|
||||
.onSizeChanged { widthPx = it.width.toFloat() }
|
||||
.then(inputModifier)
|
||||
) {
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val range = rangeEnd - rangeStart
|
||||
val displayValue = dragValue ?: value
|
||||
val fraction = if (range > 0f) {
|
||||
((displayValue.coerceIn(rangeStart, rangeEnd) - rangeStart) / range).coerceIn(0f, 1f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
val trackHeight = 4.dp.toPx()
|
||||
val thumbRadius = 7.dp.toPx()
|
||||
val centerY = size.height / 2f
|
||||
val cornerRadius = CornerRadius(trackHeight / 2f, trackHeight / 2f)
|
||||
val activeWidth = size.width * fraction
|
||||
|
||||
drawRoundRect(
|
||||
color = effectiveInactiveColor.copy(alpha = effectiveInactiveColor.alpha * disabledAlpha),
|
||||
topLeft = Offset(0f, centerY - trackHeight / 2f),
|
||||
size = Size(size.width, trackHeight),
|
||||
cornerRadius = cornerRadius
|
||||
)
|
||||
drawRoundRect(
|
||||
color = effectiveActiveColor.copy(alpha = effectiveActiveColor.alpha * disabledAlpha),
|
||||
topLeft = Offset(0f, centerY - trackHeight / 2f),
|
||||
size = Size(activeWidth, trackHeight),
|
||||
cornerRadius = cornerRadius
|
||||
)
|
||||
|
||||
markerFraction?.let { fraction ->
|
||||
val markerWidth = 2.dp.toPx()
|
||||
val markerHeight = 12.dp.toPx()
|
||||
val markerX = if (size.width <= markerWidth) {
|
||||
size.width / 2f
|
||||
} else {
|
||||
(size.width * fraction).coerceIn(markerWidth / 2f, size.width - markerWidth / 2f)
|
||||
}
|
||||
drawRoundRect(
|
||||
color = effectiveMarkerColor.copy(alpha = effectiveMarkerColor.alpha * disabledAlpha),
|
||||
topLeft = Offset(markerX - markerWidth / 2f, centerY - markerHeight / 2f),
|
||||
size = Size(markerWidth, markerHeight),
|
||||
cornerRadius = CornerRadius(markerWidth / 2f, markerWidth / 2f)
|
||||
)
|
||||
}
|
||||
|
||||
val thumbCenterX = if (size.width <= thumbRadius * 2f) {
|
||||
size.width / 2f
|
||||
} else {
|
||||
activeWidth.coerceIn(thumbRadius, size.width - thumbRadius)
|
||||
}
|
||||
drawCircle(
|
||||
color = effectiveThumbColor.copy(alpha = effectiveThumbColor.alpha * disabledAlpha),
|
||||
radius = thumbRadius,
|
||||
center = Offset(thumbCenterX, centerY)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun readerMinimalSliderMarkerFraction(
|
||||
markerValue: Float?,
|
||||
valueRange: ClosedFloatingPointRange<Float>
|
||||
): Float? {
|
||||
val marker = markerValue ?: return null
|
||||
val rangeStart = valueRange.start
|
||||
val rangeEnd = valueRange.endInclusive
|
||||
if (rangeEnd <= rangeStart) return null
|
||||
|
||||
return ((marker.coerceIn(rangeStart, rangeEnd) - rangeStart) / (rangeEnd - rangeStart))
|
||||
.coerceIn(0f, 1f)
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
package org.dueattendant149.bookreader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupPositionProvider
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun ReaderTooltipIconButton(
|
||||
tooltip: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
var hovered by remember { mutableStateOf(false) }
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
val density = LocalDensity.current
|
||||
val marginPx = with(density) { 8.dp.roundToPx() }
|
||||
val offsetPx = with(density) { 8.dp.roundToPx() }
|
||||
|
||||
LaunchedEffect(hovered, tooltip) {
|
||||
if (hovered && tooltip.isNotBlank()) {
|
||||
delay(450L)
|
||||
visible = hovered
|
||||
} else {
|
||||
visible = false
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.pointerInput(tooltip) {
|
||||
awaitPointerEventScope {
|
||||
var isHovered = false
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
when (event.type) {
|
||||
PointerEventType.Enter,
|
||||
PointerEventType.Move -> if (!isHovered) {
|
||||
isHovered = true
|
||||
hovered = true
|
||||
}
|
||||
PointerEventType.Exit,
|
||||
PointerEventType.Press -> if (isHovered) {
|
||||
isHovered = false
|
||||
hovered = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
IconButton(onClick = onClick, modifier = modifier, enabled = enabled) {
|
||||
content.invoke()
|
||||
}
|
||||
if (visible) {
|
||||
Popup(
|
||||
popupPositionProvider = ReaderTooltipPositionProvider(
|
||||
marginPx = marginPx,
|
||||
offsetPx = offsetPx
|
||||
)
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = MaterialTheme.colorScheme.inverseSurface,
|
||||
contentColor = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 4.dp
|
||||
) {
|
||||
Text(
|
||||
text = tooltip,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.widthIn(max = 260.dp)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class ReaderTooltipPositionProvider(
|
||||
private val marginPx: Int,
|
||||
private val offsetPx: Int
|
||||
) : PopupPositionProvider {
|
||||
override fun calculatePosition(
|
||||
anchorBounds: IntRect,
|
||||
windowSize: IntSize,
|
||||
layoutDirection: LayoutDirection,
|
||||
popupContentSize: IntSize
|
||||
): IntOffset {
|
||||
val centeredX = anchorBounds.left + (anchorBounds.width - popupContentSize.width) / 2
|
||||
val maxX = (windowSize.width - popupContentSize.width - marginPx).coerceAtLeast(marginPx)
|
||||
val x = centeredX.coerceIn(marginPx, maxX)
|
||||
val belowY = anchorBounds.bottom + offsetPx
|
||||
val aboveY = anchorBounds.top - popupContentSize.height - offsetPx
|
||||
val y = if (belowY + popupContentSize.height <= windowSize.height - marginPx) {
|
||||
belowY
|
||||
} else {
|
||||
aboveY.coerceAtLeast(marginPx)
|
||||
}
|
||||
return IntOffset(x, y)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,315 @@
|
|||
package org.dueattendant149.bookreader.shared.ui
|
||||
|
||||
import org.dueattendant149.bookreader.shared.PdfDisplayMode
|
||||
import org.dueattendant149.bookreader.shared.ReaderAutoScrollState
|
||||
import org.dueattendant149.bookreader.shared.ReaderExtrasState
|
||||
import org.dueattendant149.bookreader.shared.ReaderTool
|
||||
import org.dueattendant149.bookreader.shared.ReaderToolbarPreferences
|
||||
import org.dueattendant149.bookreader.shared.pdf.PdfInkTool
|
||||
import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderState
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderSessionState
|
||||
|
||||
enum class ReaderWorkspaceKind {
|
||||
EPUB,
|
||||
PDF
|
||||
}
|
||||
|
||||
enum class ReaderWorkspaceLeftSection(val title: String) {
|
||||
CONTENTS("Contents"),
|
||||
IMAGES("Images"),
|
||||
SEARCH("Search"),
|
||||
BOOKMARKS("Bookmarks"),
|
||||
NOTES("Annotations"),
|
||||
PAGES("Pages")
|
||||
}
|
||||
|
||||
enum class ReaderWorkspaceInspectorSection(val title: String) {
|
||||
APPEARANCE("Appearance"),
|
||||
TOOLS("Tools"),
|
||||
AI_TTS("AI/TTS"),
|
||||
TOOLBAR("Toolbar")
|
||||
}
|
||||
|
||||
enum class ReaderWorkspaceTopAction {
|
||||
CONTENTS,
|
||||
SEARCH,
|
||||
BOOKMARK,
|
||||
FILE_ACTIONS,
|
||||
FULL_SCREEN,
|
||||
APPEARANCE,
|
||||
READ_ALOUD,
|
||||
AI,
|
||||
TOOLS
|
||||
}
|
||||
|
||||
enum class ReaderWorkspaceBottomAction {
|
||||
PAGE_SLIDER,
|
||||
PREVIOUS,
|
||||
NEXT
|
||||
}
|
||||
|
||||
data class ReaderWorkspaceChromeModel(
|
||||
val preferAutoHide: Boolean,
|
||||
val forceVisible: Boolean,
|
||||
val forceVisibleReasons: Set<String> = emptySet(),
|
||||
val revealVisibleReasons: Set<String> = emptySet()
|
||||
)
|
||||
|
||||
internal fun readerWorkspaceChromeVisible(
|
||||
requestedVisible: Boolean,
|
||||
lockedVisible: Boolean,
|
||||
forcedVisible: Boolean
|
||||
): Boolean {
|
||||
return lockedVisible || forcedVisible || requestedVisible
|
||||
}
|
||||
|
||||
internal fun readerWorkspaceChromeVisibleAfterReaderTap(
|
||||
requestedVisible: Boolean,
|
||||
lockedVisible: Boolean,
|
||||
forcedVisible: Boolean,
|
||||
rightPanelClosedByTap: Boolean = false
|
||||
): Boolean {
|
||||
return if (lockedVisible || forcedVisible || rightPanelClosedByTap) {
|
||||
true
|
||||
} else {
|
||||
!requestedVisible
|
||||
}
|
||||
}
|
||||
|
||||
internal fun readerWorkspaceShouldCloseRightPanelAfterReaderTap(
|
||||
rightPanelOpen: Boolean,
|
||||
hasInspectorSections: Boolean,
|
||||
closeRightPanelOnReaderTap: Boolean
|
||||
): Boolean {
|
||||
return closeRightPanelOnReaderTap && rightPanelOpen && hasInspectorSections
|
||||
}
|
||||
|
||||
internal fun readerWorkspaceLeftPanelVisible(
|
||||
toggledOpen: Boolean,
|
||||
chromeVisible: Boolean,
|
||||
hasNavigationSections: Boolean
|
||||
): Boolean {
|
||||
return toggledOpen && chromeVisible && hasNavigationSections
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
data class ReaderWorkspaceModel(
|
||||
val kind: ReaderWorkspaceKind,
|
||||
val leftSections: List<ReaderWorkspaceLeftSection>,
|
||||
val inspectorSections: List<ReaderWorkspaceInspectorSection>,
|
||||
val topActions: List<ReaderWorkspaceTopAction>,
|
||||
val bottomActions: List<ReaderWorkspaceBottomAction>,
|
||||
val defaultPdfInteractionMode: PdfInkTool? = null,
|
||||
val panelDefaults: ReaderWorkspacePanelDefaults = ReaderWorkspacePanelDefaults(),
|
||||
val chrome: ReaderWorkspaceChromeModel
|
||||
)
|
||||
|
||||
fun epubReaderWorkspaceModel(
|
||||
session: ReaderSessionState,
|
||||
toolbarPreferences: ReaderToolbarPreferences,
|
||||
extrasState: ReaderExtrasState,
|
||||
aiAvailable: Boolean,
|
||||
cloudTtsAvailable: Boolean = true,
|
||||
externalLookupAvailable: Boolean = true,
|
||||
appThemeControlsAvailable: Boolean = false
|
||||
): ReaderWorkspaceModel {
|
||||
val preferences = toolbarPreferences.sanitized()
|
||||
val leftSections = listOf(
|
||||
ReaderWorkspaceLeftSection.CONTENTS,
|
||||
ReaderWorkspaceLeftSection.NOTES,
|
||||
ReaderWorkspaceLeftSection.BOOKMARKS,
|
||||
ReaderWorkspaceLeftSection.IMAGES
|
||||
)
|
||||
val inspectorSections = buildList {
|
||||
if (
|
||||
appThemeControlsAvailable ||
|
||||
preferences.isVisible(ReaderTool.THEME) ||
|
||||
preferences.isVisible(ReaderTool.FORMAT) ||
|
||||
preferences.isVisible(ReaderTool.VISUAL_OPTIONS) ||
|
||||
preferences.isVisible(ReaderTool.READING_MODE)
|
||||
) {
|
||||
add(ReaderWorkspaceInspectorSection.APPEARANCE)
|
||||
}
|
||||
if (
|
||||
(cloudTtsAvailable && (
|
||||
preferences.isVisible(ReaderTool.TTS_CONTROLS) ||
|
||||
preferences.isVisible(ReaderTool.TTS_SETTINGS)
|
||||
)) ||
|
||||
preferences.isVisible(ReaderTool.TTS_REPLACEMENTS)
|
||||
) {
|
||||
add(ReaderWorkspaceInspectorSection.AI_TTS)
|
||||
}
|
||||
}.distinct()
|
||||
val topActions = buildList {
|
||||
if (ReaderWorkspaceLeftSection.CONTENTS in leftSections) add(ReaderWorkspaceTopAction.CONTENTS)
|
||||
if (preferences.isVisible(ReaderTool.SEARCH)) add(ReaderWorkspaceTopAction.SEARCH)
|
||||
if (preferences.isVisible(ReaderTool.BOOKMARK)) add(ReaderWorkspaceTopAction.BOOKMARK)
|
||||
add(ReaderWorkspaceTopAction.FULL_SCREEN)
|
||||
if (ReaderWorkspaceInspectorSection.APPEARANCE in inspectorSections) add(ReaderWorkspaceTopAction.APPEARANCE)
|
||||
if (cloudTtsAvailable && preferences.isVisible(ReaderTool.TTS_CONTROLS)) add(ReaderWorkspaceTopAction.READ_ALOUD)
|
||||
if (aiAvailable && preferences.isVisible(ReaderTool.AI_FEATURES)) add(ReaderWorkspaceTopAction.AI)
|
||||
if (inspectorSections.isNotEmpty()) add(ReaderWorkspaceTopAction.TOOLS)
|
||||
}.distinct()
|
||||
val bottomActions = buildList {
|
||||
if (preferences.isVisible(ReaderTool.SLIDER)) add(ReaderWorkspaceBottomAction.PAGE_SLIDER)
|
||||
add(ReaderWorkspaceBottomAction.PREVIOUS)
|
||||
add(ReaderWorkspaceBottomAction.NEXT)
|
||||
}
|
||||
return ReaderWorkspaceModel(
|
||||
kind = ReaderWorkspaceKind.EPUB,
|
||||
leftSections = leftSections,
|
||||
inspectorSections = inspectorSections,
|
||||
topActions = topActions,
|
||||
bottomActions = bottomActions,
|
||||
chrome = readerWorkspaceChromeModel(
|
||||
preferAutoHide = true,
|
||||
searchActive = session.isSearchActive,
|
||||
leftPanelOpen = false,
|
||||
inspectorOpen = false,
|
||||
annotationEditing = false,
|
||||
richTextEditing = false,
|
||||
loading = false,
|
||||
errorMessage = null,
|
||||
autoScroll = ReaderAutoScrollState(),
|
||||
ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun readerWorkspaceQuickActionTools(
|
||||
toolbarPreferences: ReaderToolbarPreferences,
|
||||
bottom: Boolean,
|
||||
aiAvailable: Boolean,
|
||||
cloudTtsAvailable: Boolean = true,
|
||||
externalLookupAvailable: Boolean = true
|
||||
): List<ReaderTool> {
|
||||
val preferences = toolbarPreferences.sanitized()
|
||||
return preferences.orderedVisibleTools()
|
||||
.filter { tool ->
|
||||
tool.supportsDesktopQuickAction &&
|
||||
preferences.isBottom(tool) == bottom &&
|
||||
(tool != ReaderTool.AI_FEATURES || aiAvailable) &&
|
||||
(tool != ReaderTool.TTS_CONTROLS || cloudTtsAvailable) &&
|
||||
(tool != ReaderTool.DICTIONARY || externalLookupAvailable)
|
||||
}
|
||||
}
|
||||
|
||||
fun pdfReaderWorkspaceModel(
|
||||
state: SharedPdfReaderState,
|
||||
displayMode: PdfDisplayMode,
|
||||
hasContents: Boolean,
|
||||
hasBookmarks: Boolean,
|
||||
hasAnnotations: Boolean,
|
||||
hasEmbeddedComments: Boolean,
|
||||
searchActive: Boolean,
|
||||
annotationEditing: Boolean,
|
||||
richTextEditing: Boolean,
|
||||
loading: Boolean,
|
||||
errorMessage: String?,
|
||||
extrasState: ReaderExtrasState,
|
||||
aiAvailable: Boolean,
|
||||
cloudTtsAvailable: Boolean = true,
|
||||
externalLookupAvailable: Boolean = true
|
||||
): ReaderWorkspaceModel {
|
||||
val leftSections = buildList {
|
||||
add(ReaderWorkspaceLeftSection.CONTENTS)
|
||||
add(ReaderWorkspaceLeftSection.NOTES)
|
||||
add(ReaderWorkspaceLeftSection.BOOKMARKS)
|
||||
add(ReaderWorkspaceLeftSection.PAGES)
|
||||
}.distinct()
|
||||
val inspectorSections = listOf(
|
||||
ReaderWorkspaceInspectorSection.APPEARANCE,
|
||||
ReaderWorkspaceInspectorSection.TOOLS,
|
||||
ReaderWorkspaceInspectorSection.AI_TTS,
|
||||
ReaderWorkspaceInspectorSection.TOOLBAR
|
||||
)
|
||||
val topActions = buildList {
|
||||
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)
|
||||
if (aiAvailable) add(ReaderWorkspaceTopAction.AI)
|
||||
add(ReaderWorkspaceTopAction.TOOLS)
|
||||
}
|
||||
return ReaderWorkspaceModel(
|
||||
kind = ReaderWorkspaceKind.PDF,
|
||||
leftSections = leftSections,
|
||||
inspectorSections = inspectorSections,
|
||||
topActions = topActions,
|
||||
bottomActions = listOf(
|
||||
ReaderWorkspaceBottomAction.PAGE_SLIDER,
|
||||
ReaderWorkspaceBottomAction.PREVIOUS,
|
||||
ReaderWorkspaceBottomAction.NEXT
|
||||
),
|
||||
defaultPdfInteractionMode = null,
|
||||
chrome = readerWorkspaceChromeModel(
|
||||
preferAutoHide = true,
|
||||
searchActive = searchActive || state.searchQuery.isNotBlank(),
|
||||
leftPanelOpen = false,
|
||||
inspectorOpen = false,
|
||||
annotationEditing = annotationEditing || state.selectedAnnotationId != null || state.selectedTool != PdfInkTool.NONE,
|
||||
richTextEditing = richTextEditing,
|
||||
loading = loading,
|
||||
errorMessage = errorMessage,
|
||||
autoScroll = ReaderAutoScrollState(),
|
||||
ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun readerWorkspaceChromeModel(
|
||||
preferAutoHide: Boolean,
|
||||
searchActive: Boolean,
|
||||
leftPanelOpen: Boolean,
|
||||
inspectorOpen: Boolean,
|
||||
annotationEditing: Boolean,
|
||||
richTextEditing: Boolean,
|
||||
loading: Boolean,
|
||||
errorMessage: String?,
|
||||
autoScroll: ReaderAutoScrollState,
|
||||
ttsBusy: Boolean
|
||||
): ReaderWorkspaceChromeModel {
|
||||
val forceReasons = buildSet {
|
||||
if (searchActive) add("search")
|
||||
if (leftPanelOpen) add("left-panel")
|
||||
if (inspectorOpen) add("inspector")
|
||||
if (annotationEditing) add("annotation")
|
||||
if (richTextEditing) add("rich-text")
|
||||
if (loading) add("loading")
|
||||
if (!errorMessage.isNullOrBlank()) add("error")
|
||||
if (autoScroll.sanitized().enabled) add("auto-scroll")
|
||||
}
|
||||
val revealReasons = buildSet {
|
||||
if (ttsBusy) add("tts")
|
||||
}
|
||||
return ReaderWorkspaceChromeModel(
|
||||
preferAutoHide = preferAutoHide,
|
||||
forceVisible = forceReasons.isNotEmpty(),
|
||||
forceVisibleReasons = forceReasons,
|
||||
revealVisibleReasons = revealReasons
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,849 @@
|
|||
package org.dueattendant149.bookreader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.LibraryBooks
|
||||
import androidx.compose.material.icons.automirrored.filled.MenuBook
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.CreateNewFolder
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Feedback
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.ImportExport
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
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.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.dueattendant149.bookreader.shared.AppContrastOption
|
||||
import org.dueattendant149.bookreader.shared.AppThemeMode
|
||||
import org.dueattendant149.bookreader.shared.CustomAppTheme
|
||||
import org.dueattendant149.bookreader.shared.SharedFeaturePolicy
|
||||
import org.dueattendant149.bookreader.shared.UserData
|
||||
|
||||
enum class SharedAppTab {
|
||||
LIBRARY,
|
||||
SHELVES,
|
||||
CATALOGS,
|
||||
READER,
|
||||
SETTINGS,
|
||||
PRO,
|
||||
CUSTOM_FONTS,
|
||||
SUPPORT,
|
||||
FEEDBACK,
|
||||
ABOUT
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedAppShell(
|
||||
selectedTab: SharedAppTab,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
appThemeMode: AppThemeMode = AppThemeMode.SYSTEM,
|
||||
appContrastOption: AppContrastOption = AppContrastOption.STANDARD,
|
||||
appTextDimFactorLight: Float = 1.0f,
|
||||
appTextDimFactorDark: Float = 1.0f,
|
||||
appSeedColor: Color? = null,
|
||||
customAppThemes: List<CustomAppTheme> = emptyList(),
|
||||
isTabsEnabled: Boolean = true,
|
||||
featurePolicy: SharedFeaturePolicy = SharedFeaturePolicy.Standard,
|
||||
currentUser: UserData? = null,
|
||||
accountAvailable: Boolean = featurePolicy.aiAndCloud,
|
||||
isOssBuild: Boolean = false,
|
||||
isProUser: Boolean = false,
|
||||
isSyncEnabled: Boolean = false,
|
||||
syncAvailable: Boolean = featurePolicy.aiAndCloud,
|
||||
onSignInRequested: (() -> Unit)? = null,
|
||||
accountAvatar: (@Composable (UserData, Modifier) -> Unit)? = null,
|
||||
onSyncEnabledChange: (Boolean) -> Unit = {},
|
||||
onTabSelected: (SharedAppTab) -> Unit,
|
||||
onImportFiles: () -> Unit,
|
||||
onImportFolder: () -> Unit = {},
|
||||
onSyncRequested: () -> Unit,
|
||||
onFolderMetadataSyncRequested: (() -> Unit)? = null,
|
||||
onAppThemeModeChange: (AppThemeMode) -> Unit = {},
|
||||
onAppContrastOptionChange: (AppContrastOption) -> Unit = {},
|
||||
onAppTextDimFactorLightChange: (Float) -> Unit = {},
|
||||
onAppTextDimFactorDarkChange: (Float) -> Unit = {},
|
||||
onAppSeedColorChange: (Color?) -> Unit = {},
|
||||
onCustomAppThemeAdded: (CustomAppTheme) -> Unit = {},
|
||||
onCustomAppThemeDeleted: (String) -> Unit = {},
|
||||
onTabsEnabledChange: (Boolean) -> Unit = {},
|
||||
onAiSettingsRequested: (() -> Unit)? = null,
|
||||
content: @Composable (SharedAppTab) -> Unit
|
||||
) {
|
||||
val aiSettingsAvailable = onAiSettingsRequested != null && featurePolicy.aiAndCloud
|
||||
val shellModel = remember(selectedTab, aiSettingsAvailable, featurePolicy) {
|
||||
sharedAppShellModel(
|
||||
selectedTab = selectedTab,
|
||||
aiSettingsAvailable = aiSettingsAvailable,
|
||||
featurePolicy = featurePolicy
|
||||
)
|
||||
}
|
||||
val sidebarSyncToggleModel = remember(
|
||||
currentUser != null,
|
||||
accountAvailable,
|
||||
syncAvailable,
|
||||
isProUser,
|
||||
isSyncEnabled,
|
||||
featurePolicy
|
||||
) {
|
||||
sharedSidebarSyncToggleModel(
|
||||
isSignedIn = currentUser != null,
|
||||
accountAvailable = accountAvailable,
|
||||
syncAvailable = syncAvailable,
|
||||
isProUser = isProUser,
|
||||
isSyncEnabled = isSyncEnabled,
|
||||
featurePolicy = featurePolicy
|
||||
)
|
||||
}
|
||||
var showAppThemeSettings by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { padding ->
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(padding)
|
||||
) {
|
||||
val useSidebar = maxWidth >= 900.dp
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
if (shellModel.showPrimaryNavigation) {
|
||||
if (useSidebar) {
|
||||
SharedAppSidebar(
|
||||
selectedTab = shellModel.selectedPrimaryTab,
|
||||
primaryTabs = shellModel.primaryTabs,
|
||||
primaryActions = shellModel.primaryActions,
|
||||
currentUser = currentUser,
|
||||
accountAvailable = accountAvailable,
|
||||
isOssBuild = isOssBuild,
|
||||
syncToggleModel = sidebarSyncToggleModel,
|
||||
onAccountClick = { onTabSelected(SharedAppTab.PRO) },
|
||||
onSignInRequested = onSignInRequested,
|
||||
accountAvatar = accountAvatar,
|
||||
onSyncEnabledChange = onSyncEnabledChange,
|
||||
onTabSelected = onTabSelected,
|
||||
onPrimaryAction = { action ->
|
||||
when (action) {
|
||||
SharedAppToolAction.AI_SETTINGS -> onAiSettingsRequested?.invoke()
|
||||
else -> Unit
|
||||
}
|
||||
},
|
||||
moreMenu = {
|
||||
SharedMoreMenuButton(
|
||||
compact = false,
|
||||
moreSections = shellModel.moreSections,
|
||||
isTabsEnabled = isTabsEnabled,
|
||||
onImportFiles = onImportFiles,
|
||||
onImportFolder = onImportFolder,
|
||||
onSyncRequested = onSyncRequested,
|
||||
onFolderMetadataSyncRequested = onFolderMetadataSyncRequested,
|
||||
onAppThemeRequested = { showAppThemeSettings = true },
|
||||
onAiSettingsRequested = { onAiSettingsRequested?.invoke() },
|
||||
onOpenTab = onTabSelected,
|
||||
onTabsEnabledChange = onTabsEnabledChange
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
SharedAppCompactRail(
|
||||
selectedTab = shellModel.selectedPrimaryTab,
|
||||
primaryTabs = shellModel.primaryTabs,
|
||||
primaryActions = shellModel.primaryActions,
|
||||
onTabSelected = onTabSelected,
|
||||
onPrimaryAction = { action ->
|
||||
when (action) {
|
||||
SharedAppToolAction.AI_SETTINGS -> onAiSettingsRequested?.invoke()
|
||||
else -> Unit
|
||||
}
|
||||
},
|
||||
moreMenu = {
|
||||
SharedMoreMenuButton(
|
||||
compact = true,
|
||||
moreSections = shellModel.moreSections,
|
||||
isTabsEnabled = isTabsEnabled,
|
||||
onImportFiles = onImportFiles,
|
||||
onImportFolder = onImportFolder,
|
||||
onSyncRequested = onSyncRequested,
|
||||
onFolderMetadataSyncRequested = onFolderMetadataSyncRequested,
|
||||
onAppThemeRequested = { showAppThemeSettings = true },
|
||||
onAiSettingsRequested = { onAiSettingsRequested?.invoke() },
|
||||
onOpenTab = onTabSelected,
|
||||
onTabsEnabledChange = onTabsEnabledChange
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
) {
|
||||
content(selectedTab)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showAppThemeSettings) {
|
||||
SharedAppThemeSettingsDialog(
|
||||
appThemeMode = appThemeMode,
|
||||
appContrastOption = appContrastOption,
|
||||
appTextDimFactorLight = appTextDimFactorLight,
|
||||
appTextDimFactorDark = appTextDimFactorDark,
|
||||
appSeedColor = appSeedColor,
|
||||
customAppThemes = customAppThemes,
|
||||
onThemeModeChanged = onAppThemeModeChange,
|
||||
onContrastOptionChanged = onAppContrastOptionChange,
|
||||
onTextDimFactorLightChanged = onAppTextDimFactorLightChange,
|
||||
onTextDimFactorDarkChanged = onAppTextDimFactorDarkChange,
|
||||
onSeedColorChanged = onAppSeedColorChange,
|
||||
onCustomThemeAdded = onCustomAppThemeAdded,
|
||||
onCustomThemeDeleted = onCustomAppThemeDeleted,
|
||||
onDismiss = { showAppThemeSettings = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedAppSidebar(
|
||||
selectedTab: SharedAppTab,
|
||||
primaryTabs: List<SharedAppTab>,
|
||||
primaryActions: List<SharedAppToolAction>,
|
||||
currentUser: UserData?,
|
||||
accountAvailable: Boolean,
|
||||
isOssBuild: Boolean,
|
||||
syncToggleModel: SharedSidebarSyncToggleModel,
|
||||
onAccountClick: () -> Unit,
|
||||
onSignInRequested: (() -> Unit)?,
|
||||
accountAvatar: (@Composable (UserData, Modifier) -> Unit)?,
|
||||
onSyncEnabledChange: (Boolean) -> Unit,
|
||||
onTabSelected: (SharedAppTab) -> Unit,
|
||||
onPrimaryAction: (SharedAppToolAction) -> Unit,
|
||||
moreMenu: @Composable () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.width(SharedUiTokens.sidebarWidth)
|
||||
.fillMaxHeight(),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
tonalElevation = 0.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(SharedUiTokens.compactGap)
|
||||
) {
|
||||
SharedSidebarHeader(
|
||||
currentUser = currentUser,
|
||||
accountAvailable = accountAvailable,
|
||||
isOssBuild = isOssBuild,
|
||||
onAccountClick = onAccountClick,
|
||||
onSignInRequested = onSignInRequested,
|
||||
accountAvatar = accountAvatar
|
||||
)
|
||||
primaryTabs.forEach { tab ->
|
||||
if (tab == SharedAppTab.PRO) {
|
||||
primaryActions.forEach { action ->
|
||||
SharedSidebarButton(
|
||||
label = action.labelForPrimaryNavigation(),
|
||||
icon = action.iconForPrimaryNavigation(),
|
||||
onClick = { onPrimaryAction(action) }
|
||||
)
|
||||
}
|
||||
}
|
||||
SharedSidebarNavItem(
|
||||
tab = tab,
|
||||
selected = selectedTab == tab,
|
||||
onClick = { onTabSelected(tab) }
|
||||
)
|
||||
if (tab == SharedAppTab.PRO && syncToggleModel.visible) {
|
||||
SharedSidebarSyncToggle(
|
||||
model = syncToggleModel,
|
||||
onSyncEnabledChange = onSyncEnabledChange
|
||||
)
|
||||
}
|
||||
}
|
||||
if (SharedAppTab.PRO !in primaryTabs) {
|
||||
primaryActions.forEach { action ->
|
||||
SharedSidebarButton(
|
||||
label = action.labelForPrimaryNavigation(),
|
||||
icon = action.iconForPrimaryNavigation(),
|
||||
onClick = { onPrimaryAction(action) }
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
HorizontalDivider()
|
||||
moreMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedAppCompactRail(
|
||||
selectedTab: SharedAppTab,
|
||||
primaryTabs: List<SharedAppTab>,
|
||||
primaryActions: List<SharedAppToolAction>,
|
||||
onTabSelected: (SharedAppTab) -> Unit,
|
||||
onPrimaryAction: (SharedAppToolAction) -> Unit,
|
||||
moreMenu: @Composable () -> Unit
|
||||
) {
|
||||
NavigationRail(containerColor = MaterialTheme.colorScheme.surfaceContainerLow) {
|
||||
primaryTabs.forEach { tab ->
|
||||
if (tab == SharedAppTab.PRO) {
|
||||
primaryActions.forEach { action ->
|
||||
NavigationRailItem(
|
||||
selected = false,
|
||||
onClick = { onPrimaryAction(action) },
|
||||
icon = { Icon(action.iconForPrimaryNavigation(), contentDescription = null) },
|
||||
label = { Text(action.labelForPrimaryNavigation()) }
|
||||
)
|
||||
}
|
||||
}
|
||||
NavigationRailItem(
|
||||
selected = selectedTab == tab,
|
||||
onClick = { onTabSelected(tab) },
|
||||
icon = { Icon(tab.icon, contentDescription = null) },
|
||||
label = { Text(tab.localizedLabel()) }
|
||||
)
|
||||
}
|
||||
if (SharedAppTab.PRO !in primaryTabs) {
|
||||
primaryActions.forEach { action ->
|
||||
NavigationRailItem(
|
||||
selected = false,
|
||||
onClick = { onPrimaryAction(action) },
|
||||
icon = { Icon(action.iconForPrimaryNavigation(), contentDescription = null) },
|
||||
label = { Text(action.labelForPrimaryNavigation()) }
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
moreMenu()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSidebarHeader(
|
||||
currentUser: UserData?,
|
||||
accountAvailable: Boolean,
|
||||
isOssBuild: Boolean,
|
||||
onAccountClick: () -> Unit,
|
||||
onSignInRequested: (() -> Unit)?,
|
||||
accountAvatar: (@Composable (UserData, Modifier) -> Unit)?
|
||||
) {
|
||||
val signInRequested = onSignInRequested
|
||||
val avatarContent = accountAvatar
|
||||
when {
|
||||
isOssBuild -> {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
SharedInitialAvatar(initial = "E", modifier = Modifier.size(42.dp))
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
readerString("desktop_app_name_oss", "Episteme oss"),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
readerString("desktop_offline_oss_reader", "Offline desktop reader"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
currentUser != null -> {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f)),
|
||||
onClick = onAccountClick
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
if (avatarContent != null) {
|
||||
avatarContent(currentUser, Modifier.size(42.dp))
|
||||
} else {
|
||||
SharedInitialAvatar(initial = currentUser.initial(), modifier = Modifier.size(42.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
currentUser.displayName ?: currentUser.email ?: readerString("desktop_signed_in", "Signed in"),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
currentUser.email ?: readerString("desktop_account_and_credits", "Account & credits"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
accountAvailable && signInRequested != null -> {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f)),
|
||||
onClick = signInRequested
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.AccountCircle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(42.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
readerString("drawer_sign_in", "Sign in with Google"),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
readerString("desktop_sign_in_account_header_desc", "Sync account, Pro, and credits"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Column(Modifier.padding(horizontal = 10.dp, vertical = 12.dp)) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedInitialAvatar(
|
||||
initial: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(initial, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun UserData.initial(): String {
|
||||
return (displayName ?: email ?: "E")
|
||||
.trim()
|
||||
.firstOrNull()
|
||||
?.uppercase()
|
||||
?: "E"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSidebarNavItem(
|
||||
tab: SharedAppTab,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val containerColor = if (selected) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
val contentColor = if (selected) {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
onClick = onClick
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(tab.icon, contentDescription = null, modifier = Modifier.size(21.dp))
|
||||
Text(tab.localizedLabel(), style = MaterialTheme.typography.bodyMedium, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSidebarSyncToggle(
|
||||
model: SharedSidebarSyncToggleModel,
|
||||
onSyncEnabledChange: (Boolean) -> Unit
|
||||
) {
|
||||
val title = readerString("desktop_cloud_sync", "Cloud sync")
|
||||
val summary = when {
|
||||
!model.enabled -> readerString("desktop_pro_required", "Pro required")
|
||||
model.checked -> readerString("content_desc_enabled", "Enabled")
|
||||
else -> readerString("desktop_disabled", "Disabled")
|
||||
}
|
||||
val contentColor = if (model.enabled) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.62f)
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
contentColor = contentColor,
|
||||
onClick = {
|
||||
if (model.enabled) {
|
||||
onSyncEnabledChange(!model.checked)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Sync, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
summary,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = contentColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = model.checked,
|
||||
enabled = model.enabled,
|
||||
onCheckedChange = { checked ->
|
||||
if (model.enabled) {
|
||||
onSyncEnabledChange(checked)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSidebarButton(
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = Color.Transparent,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
onClick = onClick
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(21.dp))
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedMoreMenuButton(
|
||||
compact: Boolean,
|
||||
moreSections: List<SharedAppMoreSection>,
|
||||
isTabsEnabled: Boolean,
|
||||
onImportFiles: () -> Unit,
|
||||
onImportFolder: () -> Unit,
|
||||
onSyncRequested: () -> Unit,
|
||||
onFolderMetadataSyncRequested: (() -> Unit)?,
|
||||
onAppThemeRequested: () -> Unit,
|
||||
onAiSettingsRequested: () -> Unit,
|
||||
onOpenTab: (SharedAppTab) -> Unit,
|
||||
onTabsEnabledChange: (Boolean) -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
fun runAction(action: SharedAppToolAction) {
|
||||
expanded = false
|
||||
when (action) {
|
||||
SharedAppToolAction.SETTINGS -> onOpenTab(SharedAppTab.SETTINGS)
|
||||
SharedAppToolAction.IMPORT_FILES -> onImportFiles()
|
||||
SharedAppToolAction.IMPORT_FOLDER -> onImportFolder()
|
||||
SharedAppToolAction.SYNC -> onSyncRequested()
|
||||
SharedAppToolAction.APP_THEME -> onAppThemeRequested()
|
||||
SharedAppToolAction.PRO -> onOpenTab(SharedAppTab.PRO)
|
||||
SharedAppToolAction.AI_SETTINGS -> onAiSettingsRequested()
|
||||
SharedAppToolAction.CUSTOM_FONTS -> onOpenTab(SharedAppTab.CUSTOM_FONTS)
|
||||
SharedAppToolAction.HELP_FEEDBACK -> onOpenTab(SharedAppTab.FEEDBACK)
|
||||
SharedAppToolAction.SUPPORT -> onOpenTab(SharedAppTab.SUPPORT)
|
||||
SharedAppToolAction.ABOUT -> onOpenTab(SharedAppTab.ABOUT)
|
||||
SharedAppToolAction.TABS_TOGGLE -> onTabsEnabledChange(!isTabsEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = if (compact) Modifier else Modifier.fillMaxWidth()) {
|
||||
if (compact) {
|
||||
IconButton(onClick = { expanded = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = readerString("desktop_more_menu", "More"))
|
||||
}
|
||||
} else {
|
||||
SharedSidebarButton(
|
||||
label = readerString("desktop_more_menu", "More"),
|
||||
icon = Icons.Default.MoreVert,
|
||||
onClick = { expanded = true }
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
modifier = Modifier.width(300.dp)
|
||||
) {
|
||||
moreSections.forEachIndexed { sectionIndex, section ->
|
||||
SharedMoreMenuSectionLabel(section.group)
|
||||
section.actions.forEach { action ->
|
||||
if (action == SharedAppToolAction.SYNC && onFolderMetadataSyncRequested != null) {
|
||||
SharedMoreMenuItem(
|
||||
icon = Icons.Default.Sync,
|
||||
title = readerString("desktop_sync_metadata", "Sync metadata"),
|
||||
onClick = {
|
||||
expanded = false
|
||||
onFolderMetadataSyncRequested()
|
||||
}
|
||||
)
|
||||
SharedMoreMenuItem(
|
||||
icon = Icons.Default.Search,
|
||||
title = readerString("desktop_full_scan", "Full scan"),
|
||||
onClick = { runAction(action) }
|
||||
)
|
||||
} else {
|
||||
SharedMoreMenuItem(
|
||||
icon = action.iconForMoreMenu(),
|
||||
title = action.labelForMoreMenu(isTabsEnabled),
|
||||
checked = if (action == SharedAppToolAction.TABS_TOGGLE) isTabsEnabled else null,
|
||||
onClick = { runAction(action) }
|
||||
)
|
||||
}
|
||||
}
|
||||
if (sectionIndex != moreSections.lastIndex) {
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedMoreMenuSectionLabel(group: SharedAppMoreGroup) {
|
||||
Text(
|
||||
group.labelForMoreMenu(),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedMoreMenuItem(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
checked: Boolean? = null,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
leadingIcon = { Icon(icon, contentDescription = null, modifier = Modifier.size(20.dp)) },
|
||||
trailingIcon = checked?.let { isChecked ->
|
||||
{ Switch(checked = isChecked, onCheckedChange = null) }
|
||||
},
|
||||
onClick = onClick
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedAppToolAction.labelForPrimaryNavigation(): String {
|
||||
return when (this) {
|
||||
SharedAppToolAction.AI_SETTINGS -> readerString("desktop_ai_keys", "AI keys")
|
||||
else -> labelForMoreMenu(isTabsEnabled = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun SharedAppToolAction.iconForPrimaryNavigation(): ImageVector {
|
||||
return when (this) {
|
||||
SharedAppToolAction.AI_SETTINGS -> Icons.Default.Lock
|
||||
else -> iconForMoreMenu()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedAppMoreGroup.labelForMoreMenu(): String {
|
||||
return when (this) {
|
||||
SharedAppMoreGroup.LIBRARY -> readerString("desktop_more_library_actions", "Library actions")
|
||||
SharedAppMoreGroup.ACCOUNT -> readerString("desktop_account_and_credits", "Account & credits")
|
||||
SharedAppMoreGroup.PREFERENCES -> readerString("desktop_preferences", "Preferences")
|
||||
SharedAppMoreGroup.HELP -> readerString("desktop_help", "Help")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedAppToolAction.labelForMoreMenu(isTabsEnabled: Boolean): String {
|
||||
return when (this) {
|
||||
SharedAppToolAction.SETTINGS -> readerString("settings", "Settings")
|
||||
SharedAppToolAction.IMPORT_FILES -> readerString("desktop_import_files", "Import files")
|
||||
SharedAppToolAction.IMPORT_FOLDER -> readerString("fab_add_folder", "Add folder")
|
||||
SharedAppToolAction.SYNC -> readerString("desktop_sync_folders", "Sync folders")
|
||||
SharedAppToolAction.APP_THEME -> readerString("app_theme_title", "App theme")
|
||||
SharedAppToolAction.PRO -> readerString("desktop_account_and_credits", "Account & credits")
|
||||
SharedAppToolAction.AI_SETTINGS -> readerString("ai_settings_title", "AI keys and models")
|
||||
SharedAppToolAction.CUSTOM_FONTS -> readerString("custom_fonts", "Custom fonts")
|
||||
SharedAppToolAction.HELP_FEEDBACK -> readerString("drawer_help_feedback", "Help & feedback")
|
||||
SharedAppToolAction.SUPPORT -> readerString("drawer_support_project", "Support project")
|
||||
SharedAppToolAction.ABOUT -> readerString("about_title", "About Episteme")
|
||||
SharedAppToolAction.TABS_TOGGLE -> if (isTabsEnabled) {
|
||||
readerString("desktop_reader_tabs_on", "Reader tabs on")
|
||||
} else {
|
||||
readerString("desktop_reader_tabs_off", "Reader tabs off")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SharedAppToolAction.iconForMoreMenu(): ImageVector {
|
||||
return when (this) {
|
||||
SharedAppToolAction.SETTINGS -> Icons.Default.Settings
|
||||
SharedAppToolAction.IMPORT_FILES -> Icons.Default.ImportExport
|
||||
SharedAppToolAction.IMPORT_FOLDER -> Icons.Default.CreateNewFolder
|
||||
SharedAppToolAction.SYNC -> Icons.Default.Sync
|
||||
SharedAppToolAction.APP_THEME -> Icons.Default.Palette
|
||||
SharedAppToolAction.PRO -> Icons.Default.Star
|
||||
SharedAppToolAction.AI_SETTINGS -> Icons.Default.Settings
|
||||
SharedAppToolAction.CUSTOM_FONTS -> Icons.Default.TextFields
|
||||
SharedAppToolAction.HELP_FEEDBACK -> Icons.Default.Feedback
|
||||
SharedAppToolAction.SUPPORT -> Icons.Default.Favorite
|
||||
SharedAppToolAction.ABOUT -> Icons.Default.Info
|
||||
SharedAppToolAction.TABS_TOGGLE -> Icons.AutoMirrored.Filled.MenuBook
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedAppTab.localizedLabel(): String {
|
||||
return when (this) {
|
||||
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_account_and_credits", "Account & credits")
|
||||
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) {
|
||||
SharedAppTab.LIBRARY -> Icons.AutoMirrored.Filled.LibraryBooks
|
||||
SharedAppTab.SHELVES -> Icons.Default.Folder
|
||||
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
|
||||
SharedAppTab.ABOUT -> Icons.Default.Info
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,194 @@
|
|||
package org.dueattendant149.bookreader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.dueattendant149.bookreader.shared.ReaderMarkdownBlock
|
||||
import org.dueattendant149.bookreader.shared.ReaderMarkdownParser
|
||||
|
||||
@Composable
|
||||
fun SharedMarkdownText(
|
||||
markdown: String,
|
||||
modifier: Modifier = Modifier,
|
||||
style: TextStyle = MaterialTheme.typography.bodySmall
|
||||
) {
|
||||
val document = remember(markdown) { ReaderMarkdownParser.parse(markdown) }
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
document.blocks.forEachIndexed { index, block ->
|
||||
when (block) {
|
||||
is ReaderMarkdownBlock.Heading -> {
|
||||
val headingStyle = when (block.level) {
|
||||
1 -> MaterialTheme.typography.titleLarge
|
||||
2 -> MaterialTheme.typography.titleMedium
|
||||
else -> MaterialTheme.typography.titleSmall
|
||||
}
|
||||
Text(
|
||||
text = block.text.markdownInlineAnnotatedString(),
|
||||
style = headingStyle,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
|
||||
is ReaderMarkdownBlock.Paragraph -> {
|
||||
Text(text = block.text.markdownInlineAnnotatedString(), style = style)
|
||||
}
|
||||
|
||||
is ReaderMarkdownBlock.Quote -> {
|
||||
Text(
|
||||
text = block.text.markdownInlineAnnotatedString(),
|
||||
style = style,
|
||||
color = colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(colorScheme.surfaceVariant.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
|
||||
.padding(8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
is ReaderMarkdownBlock.CodeBlock -> {
|
||||
Surface(
|
||||
color = colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = block.text,
|
||||
style = style.copy(fontFamily = FontFamily.Monospace),
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ReaderMarkdownBlock.ListItems -> {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
block.items.forEachIndexed { itemIndex, item ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = if (block.ordered) "${itemIndex + 1}." else "-",
|
||||
style = style,
|
||||
color = colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = item.markdownInlineAnnotatedString(),
|
||||
style = style,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (document.blocks.isEmpty() && markdown.isNotBlank()) {
|
||||
Text(text = markdown, style = style)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun String.markdownInlineAnnotatedString(): AnnotatedString {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
return remember(this, colorScheme.primary, colorScheme.surfaceVariant) {
|
||||
buildAnnotatedString {
|
||||
appendMarkdownInline(
|
||||
text = this@markdownInlineAnnotatedString,
|
||||
codeStyle = SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
background = colorScheme.surfaceVariant.copy(alpha = 0.7f)
|
||||
),
|
||||
linkStyle = SpanStyle(
|
||||
color = colorScheme.primary,
|
||||
textDecoration = TextDecoration.Underline
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun AnnotatedString.Builder.appendMarkdownInline(
|
||||
text: String,
|
||||
codeStyle: SpanStyle,
|
||||
linkStyle: SpanStyle
|
||||
) {
|
||||
var index = 0
|
||||
while (index < text.length) {
|
||||
when {
|
||||
text.startsWith("`", index) -> {
|
||||
val end = text.indexOf('`', startIndex = index + 1)
|
||||
if (end > index) {
|
||||
withStyle(codeStyle) { append(text.substring(index + 1, end)) }
|
||||
index = end + 1
|
||||
} else {
|
||||
append(text[index])
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
text.startsWith("**", index) -> {
|
||||
val end = text.indexOf("**", startIndex = index + 2)
|
||||
if (end > index) {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
appendMarkdownInline(text.substring(index + 2, end), codeStyle, linkStyle)
|
||||
}
|
||||
index = end + 2
|
||||
} else {
|
||||
append(text[index])
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
text.startsWith("*", index) -> {
|
||||
val end = text.indexOf('*', startIndex = index + 1)
|
||||
if (end > index) {
|
||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
||||
appendMarkdownInline(text.substring(index + 1, end), codeStyle, linkStyle)
|
||||
}
|
||||
index = end + 1
|
||||
} else {
|
||||
append(text[index])
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
text[index] == '[' -> {
|
||||
val labelEnd = text.indexOf("](", startIndex = index + 1)
|
||||
val urlEnd = if (labelEnd > index) text.indexOf(')', startIndex = labelEnd + 2) else -1
|
||||
if (labelEnd > index && urlEnd > labelEnd) {
|
||||
withStyle(linkStyle) {
|
||||
appendMarkdownInline(text.substring(index + 1, labelEnd), codeStyle, linkStyle)
|
||||
}
|
||||
index = urlEnd + 1
|
||||
} else {
|
||||
append(text[index])
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
append(text[index])
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,883 @@
|
|||
package org.dueattendant149.bookreader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
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.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.dueattendant149.bookreader.shared.BookItem
|
||||
import org.dueattendant149.bookreader.shared.opds.OpdsAcquisition
|
||||
import org.dueattendant149.bookreader.shared.opds.OpdsCatalog
|
||||
import org.dueattendant149.bookreader.shared.opds.OpdsEntry
|
||||
import org.dueattendant149.bookreader.shared.opds.SharedOpdsLocalBookMatcher
|
||||
import org.dueattendant149.bookreader.shared.opds.SharedOpdsDownloadState
|
||||
import org.dueattendant149.bookreader.shared.opds.SharedOpdsScreenState
|
||||
import org.dueattendant149.bookreader.shared.opds.SharedOpdsText
|
||||
|
||||
@Composable
|
||||
fun SharedOpdsScreen(
|
||||
state: SharedOpdsScreenState,
|
||||
localLibraryBooks: List<BookItem>,
|
||||
onOpenCatalog: (OpdsCatalog) -> Unit,
|
||||
onOpenFeedUrl: (String) -> Unit,
|
||||
onNavigateBack: () -> Unit,
|
||||
onSearch: (String) -> Unit,
|
||||
onLoadNextPage: () -> Unit,
|
||||
onAddCatalog: (String, String, String?, String?) -> Unit,
|
||||
onUpdateCatalog: (String, String, String, String?, String?) -> Unit,
|
||||
onRemoveCatalog: (OpdsCatalog) -> Unit,
|
||||
onDownloadBook: (OpdsEntry, OpdsAcquisition) -> Unit,
|
||||
onReadBook: (BookItem) -> Unit,
|
||||
onStreamBook: (OpdsEntry, OpdsCatalog?) -> Unit,
|
||||
onClearError: () -> Unit,
|
||||
coverContent: @Composable (OpdsEntry, Modifier) -> Unit = { entry, coverModifier ->
|
||||
SharedOpdsCoverPlaceholder(entry, coverModifier)
|
||||
},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var selectedEntry by remember { mutableStateOf<OpdsEntry?>(null) }
|
||||
var showCatalogDialog by remember { mutableStateOf(false) }
|
||||
var editingCatalog by remember { mutableStateOf<OpdsCatalog?>(null) }
|
||||
var catalogToDelete by remember { mutableStateOf<OpdsCatalog?>(null) }
|
||||
|
||||
Box(modifier.fillMaxSize()) {
|
||||
if (!state.isViewingCatalog) {
|
||||
SharedOpdsCatalogList(
|
||||
catalogs = state.catalogs,
|
||||
onOpenCatalog = onOpenCatalog,
|
||||
onEditCatalog = { catalog ->
|
||||
editingCatalog = catalog
|
||||
showCatalogDialog = true
|
||||
},
|
||||
onDeleteCatalog = { catalogToDelete = it },
|
||||
onAddCatalog = {
|
||||
editingCatalog = null
|
||||
showCatalogDialog = true
|
||||
}
|
||||
)
|
||||
} else {
|
||||
SharedOpdsFeedView(
|
||||
state = state,
|
||||
localLibraryBooks = localLibraryBooks,
|
||||
onNavigateBack = onNavigateBack,
|
||||
onSearch = onSearch,
|
||||
onOpenFeedUrl = onOpenFeedUrl,
|
||||
onLoadNextPage = onLoadNextPage,
|
||||
onDownloadBook = onDownloadBook,
|
||||
onReadBook = onReadBook,
|
||||
onStreamBook = { entry -> onStreamBook(entry, state.currentCatalog) },
|
||||
onEntrySelected = { selectedEntry = it },
|
||||
coverContent = coverContent
|
||||
)
|
||||
}
|
||||
|
||||
state.errorMessage?.let { error ->
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
TextButton(onClick = onClearError) {
|
||||
Text(readerString("action_dismiss", "Dismiss"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCatalogDialog) {
|
||||
SharedOpdsCatalogDialog(
|
||||
catalog = editingCatalog,
|
||||
onDismiss = {
|
||||
showCatalogDialog = false
|
||||
editingCatalog = null
|
||||
},
|
||||
onSave = { title, url, username, password ->
|
||||
val editing = editingCatalog
|
||||
if (editing == null) {
|
||||
onAddCatalog(title, url, username, password)
|
||||
} else {
|
||||
onUpdateCatalog(editing.id, title, url, username, password)
|
||||
}
|
||||
showCatalogDialog = false
|
||||
editingCatalog = null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
catalogToDelete?.let { catalog ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { catalogToDelete = null },
|
||||
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 = {
|
||||
onRemoveCatalog(catalog)
|
||||
catalogToDelete = null
|
||||
}
|
||||
) {
|
||||
Text(readerString("action_delete", "Delete"))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { catalogToDelete = null }) {
|
||||
Text(readerString("action_cancel", "Cancel"))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
selectedEntry?.let { entry ->
|
||||
SharedOpdsEntryDetailsDialog(
|
||||
entry = entry,
|
||||
localLibraryBook = entry.findLocalBook(localLibraryBooks),
|
||||
downloadState = state.downloadingState[entry.id],
|
||||
onDismiss = { selectedEntry = null },
|
||||
onDownloadBook = { acquisition -> onDownloadBook(entry, acquisition) },
|
||||
onReadBook = onReadBook,
|
||||
onStreamBook = {
|
||||
onStreamBook(entry, state.currentCatalog)
|
||||
selectedEntry = null
|
||||
},
|
||||
onOpenFeedUrl = { url ->
|
||||
onOpenFeedUrl(url)
|
||||
selectedEntry = null
|
||||
},
|
||||
onSearch = { query ->
|
||||
onSearch(query)
|
||||
selectedEntry = null
|
||||
},
|
||||
coverContent = coverContent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsCatalogList(
|
||||
catalogs: List<OpdsCatalog>,
|
||||
onOpenCatalog: (OpdsCatalog) -> Unit,
|
||||
onEditCatalog: (OpdsCatalog) -> Unit,
|
||||
onDeleteCatalog: (OpdsCatalog) -> Unit,
|
||||
onAddCatalog: () -> Unit
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
SharedScreenScaffold(
|
||||
title = "OPDS",
|
||||
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(readerString("desktop_opds_catalog", "Catalog"))
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (catalogs.isEmpty()) {
|
||||
SharedOpdsEmptyState(onAddCatalog = onAddCatalog, modifier = Modifier.weight(1f))
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(320.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(catalogs, key = { it.id }) { catalog ->
|
||||
SharedOpdsCatalogCard(
|
||||
catalog = catalog,
|
||||
onOpenCatalog = { onOpenCatalog(catalog) },
|
||||
onEditCatalog = { onEditCatalog(catalog) },
|
||||
onDeleteCatalog = { onDeleteCatalog(catalog) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsFeedView(
|
||||
state: SharedOpdsScreenState,
|
||||
localLibraryBooks: List<BookItem>,
|
||||
onNavigateBack: () -> Unit,
|
||||
onSearch: (String) -> Unit,
|
||||
onOpenFeedUrl: (String) -> Unit,
|
||||
onLoadNextPage: () -> Unit,
|
||||
onDownloadBook: (OpdsEntry, OpdsAcquisition) -> Unit,
|
||||
onReadBook: (BookItem) -> Unit,
|
||||
onStreamBook: (OpdsEntry) -> Unit,
|
||||
onEntrySelected: (OpdsEntry) -> Unit,
|
||||
coverContent: @Composable (OpdsEntry, Modifier) -> Unit
|
||||
) {
|
||||
var showSearch by remember { mutableStateOf(false) }
|
||||
var query by remember { mutableStateOf("") }
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Surface(color = MaterialTheme.colorScheme.surface, tonalElevation = 2.dp) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(64.dp)
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
if (showSearch) {
|
||||
showSearch = false
|
||||
query = ""
|
||||
} else {
|
||||
onNavigateBack()
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = readerString("action_back", "Back"))
|
||||
}
|
||||
if (showSearch) {
|
||||
SharedStableOutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
placeholder = { Text(readerString("search_catalog_placeholder", "Search catalog")) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = {
|
||||
if (query.isNotBlank()) {
|
||||
onSearch(query)
|
||||
query = ""
|
||||
showSearch = false
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Default.Search, contentDescription = readerString("action_search", "Search"))
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = state.currentFeed?.title ?: readerString("status_loading", "Loading"),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
state.currentCatalog?.title?.let { catalogTitle ->
|
||||
Text(
|
||||
catalogTitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.searchUrlTemplate != null) {
|
||||
IconButton(onClick = { showSearch = true }) {
|
||||
Icon(Icons.Default.Search, contentDescription = readerString("action_search", "Search"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.isLoading) {
|
||||
LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val facets = state.currentFeed?.facets.orEmpty()
|
||||
if (facets.isNotEmpty()) {
|
||||
LazyRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
facets.groupBy { it.group }.forEach { (groupName, groupFacets) ->
|
||||
item(key = groupName) {
|
||||
SharedOpdsFacetMenu(
|
||||
groupName = groupName,
|
||||
facets = groupFacets,
|
||||
onOpenFeedUrl = onOpenFeedUrl
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val entries = state.currentFeed?.entries.orEmpty()
|
||||
if (entries.isEmpty() && !state.isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(readerString("feed_empty", "This feed is empty."))
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
itemsIndexed(entries, key = { index, entry -> "${entry.id}_$index" }) { index, entry ->
|
||||
if (entry.isNavigation) {
|
||||
SharedOpdsNavigationCard(entry, onOpenFeedUrl)
|
||||
} else {
|
||||
SharedOpdsBookCard(
|
||||
entry = entry,
|
||||
localLibraryBook = entry.findLocalBook(localLibraryBooks),
|
||||
downloadState = state.downloadingState[entry.id],
|
||||
onDownloadBook = { acquisition -> onDownloadBook(entry, acquisition) },
|
||||
onReadBook = onReadBook,
|
||||
onStreamBook = { onStreamBook(entry) },
|
||||
onClick = { onEntrySelected(entry) },
|
||||
coverContent = coverContent
|
||||
)
|
||||
}
|
||||
}
|
||||
state.currentFeed?.nextUrl?.let { nextUrl ->
|
||||
item(key = "load_more_$nextUrl") {
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
OutlinedButton(
|
||||
onClick = onLoadNextPage,
|
||||
enabled = !state.isLoading
|
||||
) {
|
||||
Text(if (state.isLoading) "Loading..." else "Load more")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsCatalogCard(
|
||||
catalog: OpdsCatalog,
|
||||
onOpenCatalog: () -> Unit,
|
||||
onEditCatalog: () -> Unit,
|
||||
onDeleteCatalog: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
onClick = onOpenCatalog,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
) {
|
||||
Box(Modifier.size(46.dp), contentAlignment = Alignment.Center) {
|
||||
Icon(Icons.Default.Cloud, contentDescription = null)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(catalog.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
catalog.url,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (catalog.isDefault) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
shape = RoundedCornerShape(6.dp)
|
||||
) {
|
||||
Text(
|
||||
readerString("preset_label", "Preset"),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (!catalog.isDefault) {
|
||||
IconButton(onClick = onEditCatalog) {
|
||||
Icon(Icons.Default.Edit, contentDescription = readerString("action_edit", "Edit"))
|
||||
}
|
||||
IconButton(onClick = onDeleteCatalog) {
|
||||
Icon(Icons.Default.Delete, contentDescription = readerString("action_delete", "Delete"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsEmptyState(onAddCatalog: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f))
|
||||
) {
|
||||
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(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(readerString("fab_add_catalog", "Add catalog"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsFacetMenu(
|
||||
groupName: String,
|
||||
facets: List<org.dueattendant149.bookreader.shared.opds.OpdsFacet>,
|
||||
onOpenFeedUrl: (String) -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val activeFacet = facets.firstOrNull { it.isActive } ?: facets.firstOrNull()
|
||||
Box {
|
||||
FilterChip(
|
||||
selected = activeFacet?.isActive == true,
|
||||
onClick = { expanded = true },
|
||||
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 }) {
|
||||
facets.forEach { facet ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(facet.title) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onOpenFeedUrl(facet.url)
|
||||
},
|
||||
trailingIcon = if (facet.isActive) {
|
||||
{ Icon(Icons.Default.Check, contentDescription = null) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsNavigationCard(entry: OpdsEntry, onOpenFeedUrl: (String) -> Unit) {
|
||||
Surface(
|
||||
onClick = { entry.navigationUrl?.let(onOpenFeedUrl) },
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Folder, contentDescription = null, tint = MaterialTheme.colorScheme.secondary)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(entry.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
val summary = SharedOpdsText.cleanSummary(entry.summary)
|
||||
if (summary.isNotBlank()) {
|
||||
Text(
|
||||
summary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsBookCard(
|
||||
entry: OpdsEntry,
|
||||
localLibraryBook: BookItem?,
|
||||
downloadState: SharedOpdsDownloadState?,
|
||||
onDownloadBook: (OpdsAcquisition) -> Unit,
|
||||
onReadBook: (BookItem) -> Unit,
|
||||
onStreamBook: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
coverContent: @Composable (OpdsEntry, Modifier) -> Unit
|
||||
) {
|
||||
val uniqueAcquisitions = remember(entry.acquisitions) {
|
||||
entry.acquisitions.distinctBy { it.formatName }.sortedByDescending { it.priority }
|
||||
}
|
||||
val isDownloading = downloadState?.isDownloading == true
|
||||
var showFormatMenu by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(modifier = Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
coverContent(entry, Modifier.size(width = 70.dp, height = 100.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(entry.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
entry.author?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
||||
}
|
||||
val summary = SharedOpdsText.cleanSummary(entry.summary)
|
||||
if (summary.isNotBlank()) {
|
||||
Text(summary, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
when {
|
||||
localLibraryBook != null -> {
|
||||
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(readerString("action_read", "Read"))
|
||||
}
|
||||
}
|
||||
isDownloading -> SharedOpdsDownloadProgress(downloadState)
|
||||
else -> Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (entry.isStreamable) {
|
||||
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(readerString("action_stream", "Stream"))
|
||||
}
|
||||
}
|
||||
Box {
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
when (uniqueAcquisitions.size) {
|
||||
0 -> Unit
|
||||
1 -> onDownloadBook(uniqueAcquisitions.first())
|
||||
else -> showFormatMenu = true
|
||||
}
|
||||
},
|
||||
enabled = uniqueAcquisitions.isNotEmpty(),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
|
||||
) {
|
||||
Icon(
|
||||
if (uniqueAcquisitions.isEmpty()) Icons.Default.Info else Icons.Default.Download,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
if (uniqueAcquisitions.isEmpty()) {
|
||||
readerString("action_unavailable", "Unavailable")
|
||||
} else {
|
||||
readerString("action_download", "Download")
|
||||
}
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = showFormatMenu, onDismissRequest = { showFormatMenu = false }) {
|
||||
uniqueAcquisitions.forEach { acquisition ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(acquisition.formatName) },
|
||||
onClick = {
|
||||
showFormatMenu = false
|
||||
onDownloadBook(acquisition)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsDownloadProgress(downloadState: SharedOpdsDownloadState?) {
|
||||
val progress = downloadState?.progress
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(readerString("status_downloading", "Downloading"), style = MaterialTheme.typography.labelMedium)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (progress != null) {
|
||||
Text("${(progress * 100).toInt()}%", style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
if (progress != null) {
|
||||
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
|
||||
} else {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsEntryDetailsDialog(
|
||||
entry: OpdsEntry,
|
||||
localLibraryBook: BookItem?,
|
||||
downloadState: SharedOpdsDownloadState?,
|
||||
onDismiss: () -> Unit,
|
||||
onDownloadBook: (OpdsAcquisition) -> Unit,
|
||||
onReadBook: (BookItem) -> Unit,
|
||||
onStreamBook: () -> Unit,
|
||||
onOpenFeedUrl: (String) -> Unit,
|
||||
onSearch: (String) -> Unit,
|
||||
coverContent: @Composable (OpdsEntry, Modifier) -> Unit
|
||||
) {
|
||||
val uniqueAcquisitions = remember(entry.acquisitions) {
|
||||
entry.acquisitions.distinctBy { it.formatName }.sortedByDescending { it.priority }
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Column {
|
||||
Text(entry.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
entry.author?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.heightIn(max = 520.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.Top) {
|
||||
coverContent(entry, Modifier.size(width = 96.dp, height = 140.dp))
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
entry.series?.takeIf { it.isNotBlank() }?.let { series ->
|
||||
Text(
|
||||
text = if (entry.seriesIndex.isNullOrBlank()) series else "$series #${entry.seriesIndex}",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
val secondary = listOfNotNull(
|
||||
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) }
|
||||
}
|
||||
}
|
||||
localLibraryBook?.let { book ->
|
||||
Button(onClick = { onReadBook(book) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(Icons.Default.Check, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(readerString("action_read", "Read"))
|
||||
}
|
||||
}
|
||||
if (downloadState?.isDownloading == true) {
|
||||
SharedOpdsDownloadProgress(downloadState)
|
||||
} else {
|
||||
if (entry.isStreamable) {
|
||||
Button(onClick = onStreamBook, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(Icons.Default.Cloud, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(readerString("action_stream_now", "Stream now"))
|
||||
}
|
||||
}
|
||||
if (uniqueAcquisitions.isNotEmpty()) {
|
||||
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) }) {
|
||||
Text(acquisition.formatName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (entry.authors.isNotEmpty()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(readerString("desktop_authors", "Authors"), style = MaterialTheme.typography.labelLarge)
|
||||
entry.authors.forEach { author ->
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (author.url != null) onOpenFeedUrl(author.url) else onSearch(author.name)
|
||||
}
|
||||
) {
|
||||
Text(author.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (entry.categories.isNotEmpty()) {
|
||||
Text(readerString("desktop_categories", "Categories"), style = MaterialTheme.typography.labelLarge)
|
||||
entry.categories.distinct().take(8).forEach { category ->
|
||||
TextButton(onClick = { onSearch(category) }) {
|
||||
Text(category)
|
||||
}
|
||||
}
|
||||
}
|
||||
val summary = SharedOpdsText.cleanSummary(entry.summary)
|
||||
if (summary.isNotBlank()) {
|
||||
Text(readerString("synopsis", "Synopsis"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
|
||||
Text(summary, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(readerString("action_close", "Close"))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsCatalogDialog(
|
||||
catalog: OpdsCatalog?,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (String, String, String?, String?) -> Unit
|
||||
) {
|
||||
var title by remember(catalog) { mutableStateOf(catalog?.title.orEmpty()) }
|
||||
var url by remember(catalog) { mutableStateOf(catalog?.url.orEmpty()) }
|
||||
var username by remember(catalog) { mutableStateOf(catalog?.username.orEmpty()) }
|
||||
var password by remember(catalog) { mutableStateOf(catalog?.password.orEmpty()) }
|
||||
val isEditMode = catalog != null
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
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(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(readerString("password", "Password")) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
selectionKey = catalog?.id ?: "new:password"
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onSave(title, url, username, password) },
|
||||
enabled = title.isNotBlank() && url.isNotBlank()
|
||||
) {
|
||||
Text(readerString("action_save", "Save"))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(readerString("action_cancel", "Cancel"))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun OpdsEntry.findLocalBook(localLibraryBooks: List<BookItem>): BookItem? {
|
||||
return SharedOpdsLocalBookMatcher.findBook(this, localLibraryBooks)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsCoverPlaceholder(entry: OpdsEntry, modifier: Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(entry.title.take(1).uppercase(), style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,265 @@
|
|||
package org.dueattendant149.bookreader.shared.ui
|
||||
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.isSpecified
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextController
|
||||
import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextLog
|
||||
import org.dueattendant149.bookreader.shared.pdf.sharedPdfRichTextSelectionBounds
|
||||
import org.dueattendant149.bookreader.shared.pdf.withoutTrailingSharedPdfPageBreak
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun SharedPdfRichTextHiddenInput(
|
||||
controller: SharedPdfRichTextController,
|
||||
enabled: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
LaunchedEffect(enabled, controller.activePageIndex) {
|
||||
SharedPdfRichTextLog.d(
|
||||
"ui.hiddenInput enabled=$enabled activePage=${controller.activePageIndex} " +
|
||||
"editingLen=${controller.editingValue.text.length} selection=${controller.editingValue.selection}"
|
||||
)
|
||||
if (enabled && controller.activePageIndex != -1) {
|
||||
controller.requestEditingFocus()
|
||||
delay(16)
|
||||
controller.requestEditingFocus()
|
||||
}
|
||||
}
|
||||
|
||||
if (!enabled) return
|
||||
|
||||
BasicTextField(
|
||||
value = controller.editingValue,
|
||||
onValueChange = controller::onValueChanged,
|
||||
textStyle = TextStyle(
|
||||
color = controller.currentStyle.color,
|
||||
fontSize = controller.currentStyle.fontSize,
|
||||
fontWeight = controller.currentStyle.fontWeight,
|
||||
fontStyle = controller.currentStyle.fontStyle,
|
||||
textDecoration = controller.currentStyle.textDecoration
|
||||
),
|
||||
modifier = modifier
|
||||
.size(1.dp)
|
||||
.alpha(0f)
|
||||
.clearAndSetSemantics { }
|
||||
.focusRequester(controller.focusRequester)
|
||||
.onKeyEvent { event ->
|
||||
event.type == KeyEventType.KeyDown &&
|
||||
event.key == Key.Backspace &&
|
||||
controller.handleBackspaceAtStart()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedPdfRichTextLayer(
|
||||
pageIndex: Int,
|
||||
controller: SharedPdfRichTextController,
|
||||
pageWidth: Float,
|
||||
pageHeight: Float,
|
||||
isTextEditingEnabled: Boolean,
|
||||
centeringOffsetX: Float = 0f,
|
||||
centeringOffsetY: Float = 0f,
|
||||
isDarkMode: Boolean = false,
|
||||
isScrolling: Boolean = false,
|
||||
onPageTapped: (Int) -> Unit = {}
|
||||
) {
|
||||
LaunchedEffect(pageIndex, pageWidth, pageHeight, isTextEditingEnabled) {
|
||||
if (pageWidth <= 0f || pageHeight <= 0f) {
|
||||
SharedPdfRichTextLog.d(
|
||||
"ui.layer invalidSize page=$pageIndex size=${pageWidth.richTextUiFloat()}x${pageHeight.richTextUiFloat()} " +
|
||||
"editing=$isTextEditingEnabled"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (pageWidth <= 0f || pageHeight <= 0f) return
|
||||
|
||||
val density = LocalDensity.current
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
|
||||
LaunchedEffect(pageWidth, pageHeight, density, textMeasurer) {
|
||||
controller.updateLayoutConfig(pageWidth, pageHeight, density, textMeasurer)
|
||||
}
|
||||
|
||||
val pageLayout = remember(controller.pageLayouts, pageIndex) {
|
||||
controller.pageLayouts.find { it.pageIndex == pageIndex }
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
pageIndex,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
isTextEditingEnabled,
|
||||
controller.activePageIndex,
|
||||
pageLayout?.globalStartIndex,
|
||||
pageLayout?.globalEndIndex
|
||||
) {
|
||||
SharedPdfRichTextLog.d(
|
||||
"ui.layer page=$pageIndex size=${pageWidth.richTextUiFloat()}x${pageHeight.richTextUiFloat()} " +
|
||||
"editing=$isTextEditingEnabled activePage=${controller.activePageIndex} " +
|
||||
"layout=${pageLayout?.globalStartIndex}-${pageLayout?.globalEndIndex} " +
|
||||
"visibleLen=${pageLayout?.visibleText?.length ?: 0}"
|
||||
)
|
||||
}
|
||||
|
||||
val marginX = pageWidth * 0.1f
|
||||
val marginY = pageHeight * 0.08f
|
||||
val editorWidth = (pageWidth - (marginX * 2f)).coerceAtLeast(10f)
|
||||
val editorHeight = (pageHeight - (marginY * 2f)).coerceAtLeast(10f)
|
||||
val editorWidthDp = with(density) { editorWidth.toDp() }
|
||||
val editorHeightDp = with(density) { editorHeight.toDp() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset {
|
||||
IntOffset(
|
||||
(centeringOffsetX + marginX).roundToInt(),
|
||||
(centeringOffsetY + marginY).roundToInt()
|
||||
)
|
||||
}
|
||||
.size(editorWidthDp, editorHeightDp)
|
||||
.graphicsLayer()
|
||||
.clipToBounds()
|
||||
.then(
|
||||
if (isTextEditingEnabled) {
|
||||
Modifier.pointerInput(
|
||||
pageIndex,
|
||||
editorWidth,
|
||||
editorHeight,
|
||||
controller.activePageIndex,
|
||||
pageLayout?.globalStartIndex,
|
||||
pageLayout?.globalEndIndex
|
||||
) {
|
||||
detectTapGestures { tapOffset ->
|
||||
SharedPdfRichTextLog.d(
|
||||
"ui.layer.tap page=$pageIndex offset=${tapOffset.richTextUiOffsetSummary()} " +
|
||||
"editor=${editorWidth.richTextUiFloat()}x${editorHeight.richTextUiFloat()} " +
|
||||
"activePage=${controller.activePageIndex} hasLayout=${pageLayout != null}"
|
||||
)
|
||||
onPageTapped(pageIndex)
|
||||
controller.handleTapOnPage(pageIndex, tapOffset)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
) {
|
||||
val textToRender = if (controller.activePageIndex == pageIndex) {
|
||||
controller.localTextFieldValue.annotatedString
|
||||
} else {
|
||||
pageLayout?.visibleText?.withoutTrailingSharedPdfPageBreak()
|
||||
} ?: return@Box
|
||||
|
||||
val measureResult = remember(textToRender, editorWidth, density) {
|
||||
textMeasurer.measure(
|
||||
text = textToRender,
|
||||
style = TextStyle(fontSize = 16.sp),
|
||||
constraints = Constraints(maxWidth = editorWidth.toInt()),
|
||||
density = density
|
||||
)
|
||||
}
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
measureResult.multiParagraph.paint(drawContext.canvas)
|
||||
}
|
||||
|
||||
if (isTextEditingEnabled && controller.activePageIndex == pageIndex) {
|
||||
val selection = controller.editingValue.selection
|
||||
|
||||
sharedPdfRichTextSelectionBounds(
|
||||
selectionStart = selection.start,
|
||||
selectionEnd = selection.end,
|
||||
textLength = textToRender.length
|
||||
)?.let { (localStart, localEnd) ->
|
||||
val selectionPath = measureResult.getPathForRange(localStart, localEnd)
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
drawPath(selectionPath, Color(0xFFB3D7FF).copy(alpha = 0.5f))
|
||||
}
|
||||
}
|
||||
|
||||
if (selection.collapsed && controller.isCursorVisible) {
|
||||
val localStart = selection.start.coerceIn(0, textToRender.length)
|
||||
val alpha = if (isScrolling) {
|
||||
1f
|
||||
} else {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "pdfRichCursor")
|
||||
infiniteTransition.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 0f,
|
||||
animationSpec = infiniteRepeatable(tween(500), RepeatMode.Reverse),
|
||||
label = "pdfRichCursorAlpha"
|
||||
).value
|
||||
}
|
||||
val cursorRect = measureResult.getCursorRect(localStart)
|
||||
val styleFontSize = controller.currentStyle.fontSize
|
||||
val cursorHeight = if (styleFontSize.isSpecified) {
|
||||
with(density) { styleFontSize.toPx() } * 1.2f
|
||||
} else {
|
||||
cursorRect.height
|
||||
}
|
||||
val centerY = cursorRect.center.y
|
||||
val cursorColor = if (isDarkMode) Color.White else Color.Black
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
drawLine(
|
||||
color = cursorColor.copy(alpha = alpha),
|
||||
start = Offset(cursorRect.left, centerY - cursorHeight / 2f),
|
||||
end = Offset(cursorRect.left, centerY + cursorHeight / 2f),
|
||||
strokeWidth = 2.dp.toPx()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Float.richTextUiFloat(): String {
|
||||
return if (isFinite()) {
|
||||
val rounded = kotlin.math.round(this * 10f) / 10f
|
||||
rounded.toString()
|
||||
} else {
|
||||
toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Offset.richTextUiOffsetSummary(): String {
|
||||
return "(${x.richTextUiFloat()},${y.richTextUiFloat()})"
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,82 @@
|
|||
package org.dueattendant149.bookreader.shared.ui
|
||||
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
internal data class SharedReaderModalAnchorBounds(
|
||||
val leftPx: Float,
|
||||
val topPx: Float,
|
||||
val widthPx: Float,
|
||||
val heightPx: Float
|
||||
)
|
||||
|
||||
internal val LocalSharedReaderModalAnchorBounds = compositionLocalOf<SharedReaderModalAnchorBounds?> { null }
|
||||
internal val LocalSharedReaderModalFocusableOverride = compositionLocalOf<Boolean?> { null }
|
||||
|
||||
internal enum class SharedReaderModalLevel {
|
||||
Panel,
|
||||
PanelLeft,
|
||||
PanelRight,
|
||||
Popup,
|
||||
ChromeTop,
|
||||
ChromeBottom
|
||||
}
|
||||
|
||||
val SharedReaderPopupDefaultMaxWidth = 440.dp
|
||||
private val SharedReaderPopupMinWidth = 320.dp
|
||||
private const val SharedReaderPopupWidthFraction = 0.58f
|
||||
|
||||
fun sharedReaderPopupWidth(
|
||||
availableWidth: Dp,
|
||||
maxWidth: Dp = SharedReaderPopupDefaultMaxWidth,
|
||||
minWidth: Dp = SharedReaderPopupMinWidth,
|
||||
widthFraction: Float = SharedReaderPopupWidthFraction
|
||||
): Dp {
|
||||
if (availableWidth <= 0.dp) return 0.dp
|
||||
val lowerBound = minWidth.coerceAtMost(availableWidth)
|
||||
val upperBound = maxWidth.coerceAtMost(availableWidth).coerceAtLeast(lowerBound)
|
||||
return (availableWidth * widthFraction.coerceIn(0f, 1f)).coerceIn(lowerBound, upperBound)
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
internal fun SharedReaderModalLayer(
|
||||
onDismiss: () -> Unit,
|
||||
level: SharedReaderModalLevel = SharedReaderModalLevel.Popup,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun sharedReaderModalLayerUsesSizedEdgeWindow(level: SharedReaderModalLevel): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedReaderModalOwnerWindowProvider(
|
||||
ownerWindow: Any?,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
content()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedReaderPopupLayer(
|
||||
onDismiss: () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
SharedReaderModalLayer(
|
||||
onDismiss = onDismiss,
|
||||
level = SharedReaderModalLevel.Popup,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,509 @@
|
|||
package org.dueattendant149.bookreader.shared.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private data class SharedScrollbarState(
|
||||
val progress: Float,
|
||||
val preferredThumbHeightPx: Float,
|
||||
val contentHeightPx: Float,
|
||||
val viewportHeightPx: Float
|
||||
)
|
||||
|
||||
private data class SharedPdfScrollbarState(
|
||||
val progress: Float,
|
||||
val contentHeightPx: Float,
|
||||
val viewportHeightPx: Float
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SharedReaderVerticalScrollbar(
|
||||
listState: LazyListState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scrollbarState by remember(listState) {
|
||||
derivedStateOf {
|
||||
val layoutInfo = listState.layoutInfo
|
||||
val totalItems = layoutInfo.totalItemsCount
|
||||
val visibleItems = layoutInfo.visibleItemsInfo
|
||||
val viewportHeight = layoutInfo.viewportSize.height.toFloat()
|
||||
if (totalItems == 0 || visibleItems.isEmpty() || viewportHeight <= 0f) {
|
||||
return@derivedStateOf null
|
||||
}
|
||||
|
||||
val averageItemHeight = visibleItems.sumOf { it.size }.toFloat() / visibleItems.size
|
||||
val contentHeight = (averageItemHeight * totalItems).coerceAtLeast(viewportHeight)
|
||||
val viewportRatio = viewportHeight / contentHeight
|
||||
if (viewportRatio >= 1f) return@derivedStateOf null
|
||||
|
||||
val maxThumbHeight = viewportHeight / 2f
|
||||
val minThumbHeight = minOf(80f, maxThumbHeight)
|
||||
val thumbHeight = (viewportHeight * viewportRatio).coerceIn(minThumbHeight, maxThumbHeight)
|
||||
val currentScroll = (listState.firstVisibleItemIndex * averageItemHeight) +
|
||||
listState.firstVisibleItemScrollOffset
|
||||
val maxScroll = contentHeight - viewportHeight
|
||||
val progress = (currentScroll / maxScroll).coerceIn(0f, 1f)
|
||||
|
||||
SharedScrollbarState(
|
||||
progress = progress,
|
||||
preferredThumbHeightPx = thumbHeight,
|
||||
contentHeightPx = contentHeight,
|
||||
viewportHeightPx = viewportHeight
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val state = scrollbarState ?: return
|
||||
val density = LocalDensity.current
|
||||
var isDraggingScrollbar by remember { mutableStateOf(false) }
|
||||
var scrollbarVisible by remember { mutableStateOf(false) }
|
||||
var scrollInteractionTick by remember { mutableIntStateOf(0) }
|
||||
var scrollbarTrackHeight by remember { mutableStateOf(0f) }
|
||||
|
||||
LaunchedEffect(listState) {
|
||||
var previousIndex = listState.firstVisibleItemIndex
|
||||
var previousOffset = listState.firstVisibleItemScrollOffset
|
||||
snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
|
||||
.collect { (index, offset) ->
|
||||
if (index != previousIndex || abs(offset - previousOffset) > 1) {
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
previousIndex = index
|
||||
previousOffset = offset
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(scrollInteractionTick, isDraggingScrollbar) {
|
||||
if (isDraggingScrollbar) {
|
||||
scrollbarVisible = true
|
||||
} else if (scrollInteractionTick > 0) {
|
||||
scrollbarVisible = true
|
||||
delay(5_000)
|
||||
scrollbarVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
val scrollbarAlpha by animateFloatAsState(
|
||||
targetValue = if (scrollbarVisible || isDraggingScrollbar) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = 300),
|
||||
label = "sharedReaderScrollbarAlpha"
|
||||
)
|
||||
val activeThemeColor = MaterialTheme.colorScheme.primary
|
||||
val idleColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.72f)
|
||||
val barColor by animateColorAsState(
|
||||
targetValue = if (isDraggingScrollbar) activeThemeColor else idleColor,
|
||||
label = "sharedReaderScrollbarColor"
|
||||
)
|
||||
val scrollbarIdleWidth = 4.dp
|
||||
val scrollbarActiveWidth = 8.dp
|
||||
val barWidth by animateDpAsState(
|
||||
targetValue = if (isDraggingScrollbar) scrollbarActiveWidth else scrollbarIdleWidth,
|
||||
label = "sharedReaderScrollbarWidth"
|
||||
)
|
||||
val preferredThumbHeight = with(density) { state.preferredThumbHeightPx.toDp() }
|
||||
val scrollbarIdleHeight = preferredThumbHeight.coerceAtLeast(40.dp)
|
||||
val scrollbarActiveHeight = preferredThumbHeight.coerceAtLeast(60.dp)
|
||||
val barHeight by animateDpAsState(
|
||||
targetValue = if (isDraggingScrollbar) scrollbarActiveHeight else scrollbarIdleHeight,
|
||||
label = "sharedReaderScrollbarHeight"
|
||||
)
|
||||
val currentScrollbarTrackHeight by rememberUpdatedState(scrollbarTrackHeight)
|
||||
val currentScrollbarContentHeight by rememberUpdatedState(state.contentHeightPx)
|
||||
val currentScrollbarViewportHeight by rememberUpdatedState(state.viewportHeightPx)
|
||||
val currentScrollbarProgress by rememberUpdatedState(state.progress)
|
||||
val currentScrollbarActiveThumbHeight by rememberUpdatedState(with(density) { scrollbarActiveHeight.toPx() })
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxHeight()
|
||||
.width(36.dp)
|
||||
.padding(top = 8.dp, bottom = 8.dp)
|
||||
.onGloballyPositioned { coordinates ->
|
||||
scrollbarTrackHeight = coordinates.size.height.toFloat()
|
||||
}
|
||||
.pointerInput(listState) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
try {
|
||||
isDraggingScrollbar = true
|
||||
scrollbarVisible = true
|
||||
scrollInteractionTick += 1
|
||||
down.consume()
|
||||
|
||||
val initialContentHeight = currentScrollbarContentHeight
|
||||
val initialViewportHeight = currentScrollbarViewportHeight
|
||||
val initialMaxScroll = (initialContentHeight - initialViewportHeight).coerceAtLeast(0f)
|
||||
val initialTrackHeight = currentScrollbarTrackHeight.takeIf { it > 0f }
|
||||
?: initialViewportHeight
|
||||
val initialThumbHeight = currentScrollbarActiveThumbHeight.coerceAtLeast(1f)
|
||||
val initialTrackSpace = (initialTrackHeight - initialThumbHeight).coerceAtLeast(1f)
|
||||
val initialThumbTop = (initialTrackSpace * currentScrollbarProgress)
|
||||
.coerceIn(0f, initialTrackSpace)
|
||||
val downY = down.position.y.coerceIn(0f, initialTrackHeight)
|
||||
val thumbDragOffset = if (downY in initialThumbTop..(initialThumbTop + initialThumbHeight)) {
|
||||
downY - initialThumbTop
|
||||
} else {
|
||||
initialThumbHeight / 2f
|
||||
}
|
||||
var lastScrollPx = currentScrollbarProgress * initialMaxScroll
|
||||
|
||||
fun scrollToPointer(pointerY: Float) {
|
||||
val contentHeight = currentScrollbarContentHeight
|
||||
val viewportHeight = currentScrollbarViewportHeight
|
||||
val maxScroll = (contentHeight - viewportHeight).coerceAtLeast(0f)
|
||||
if (maxScroll <= 0f) return
|
||||
val trackHeight = currentScrollbarTrackHeight.takeIf { it > 0f }
|
||||
?: viewportHeight
|
||||
val thumbHeight = currentScrollbarActiveThumbHeight.coerceAtLeast(1f)
|
||||
val trackSpace = (trackHeight - thumbHeight).coerceAtLeast(1f)
|
||||
val targetThumbTop = (pointerY - thumbDragOffset).coerceIn(0f, trackSpace)
|
||||
val targetScrollPx = (targetThumbTop / trackSpace) * maxScroll
|
||||
val scrollDelta = targetScrollPx - lastScrollPx
|
||||
if (abs(scrollDelta) > 0.01f) {
|
||||
listState.dispatchRawDelta(scrollDelta)
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
lastScrollPx = targetScrollPx
|
||||
}
|
||||
|
||||
scrollToPointer(down.position.y)
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
if (change == null || !change.pressed) break
|
||||
|
||||
if (change.position.y != change.previousPosition.y) {
|
||||
change.consume()
|
||||
scrollToPointer(change.position.y)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isDraggingScrollbar = false
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
val thumbHeightPx = with(density) { barHeight.toPx() }
|
||||
val effectiveTrackHeight = scrollbarTrackHeight.takeIf { it > 0f } ?: state.viewportHeightPx
|
||||
val availableSpace = (effectiveTrackHeight - thumbHeightPx).coerceAtLeast(0f)
|
||||
val thumbY = (availableSpace * state.progress).coerceIn(0f, availableSpace)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(x = 0, y = thumbY.roundToInt()) }
|
||||
.align(Alignment.TopEnd)
|
||||
.alpha(scrollbarAlpha)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
modifier = Modifier
|
||||
.height(barHeight)
|
||||
.width(36.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = barWidth, height = barHeight)
|
||||
.background(barColor, RoundedCornerShape(999.dp))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedPdfVerticalScrollbar(
|
||||
listState: LazyListState,
|
||||
pageCount: Int,
|
||||
currentPage: Int,
|
||||
isDarkMode: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scrollbarState by remember(listState) {
|
||||
derivedStateOf {
|
||||
val layoutInfo = listState.layoutInfo
|
||||
val visibleItems = layoutInfo.visibleItemsInfo
|
||||
val viewportHeight = layoutInfo.viewportSize.height.toFloat()
|
||||
val totalItems = layoutInfo.totalItemsCount
|
||||
if (totalItems == 0 || visibleItems.isEmpty() || viewportHeight <= 0f) {
|
||||
return@derivedStateOf null
|
||||
}
|
||||
|
||||
val averageItemHeight = visibleItems.sumOf { it.size }.toFloat() / visibleItems.size
|
||||
val contentHeight = (averageItemHeight * totalItems).coerceAtLeast(viewportHeight)
|
||||
val maxScroll = contentHeight - viewportHeight
|
||||
if (maxScroll <= 1f) return@derivedStateOf null
|
||||
|
||||
val currentScroll = (listState.firstVisibleItemIndex * averageItemHeight) +
|
||||
listState.firstVisibleItemScrollOffset
|
||||
SharedPdfScrollbarState(
|
||||
progress = (currentScroll / maxScroll).coerceIn(0f, 1f),
|
||||
contentHeightPx = contentHeight,
|
||||
viewportHeightPx = viewportHeight
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val state = scrollbarState ?: return
|
||||
val density = LocalDensity.current
|
||||
var isDraggingScrollbar by remember { mutableStateOf(false) }
|
||||
var scrollbarVisible by remember { mutableStateOf(false) }
|
||||
var scrollInteractionTick by remember { mutableIntStateOf(0) }
|
||||
var scrollbarTrackHeight by remember { mutableStateOf(0f) }
|
||||
|
||||
LaunchedEffect(listState) {
|
||||
var previousIndex = listState.firstVisibleItemIndex
|
||||
var previousOffset = listState.firstVisibleItemScrollOffset
|
||||
snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
|
||||
.collect { (index, offset) ->
|
||||
if (index != previousIndex || abs(offset - previousOffset) > 1) {
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
previousIndex = index
|
||||
previousOffset = offset
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(scrollInteractionTick, isDraggingScrollbar) {
|
||||
if (isDraggingScrollbar) {
|
||||
scrollbarVisible = true
|
||||
} else if (scrollInteractionTick > 0) {
|
||||
scrollbarVisible = true
|
||||
delay(5_000)
|
||||
scrollbarVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
val scrollbarAlpha by animateFloatAsState(
|
||||
targetValue = if (scrollbarVisible || isDraggingScrollbar) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = 300),
|
||||
label = "sharedPdfScrollbarAlpha"
|
||||
)
|
||||
val activeThemeColor = if (isDarkMode) Color(0xFF1976D2) else Color(0xFF4285F4)
|
||||
val idleColor = if (isDarkMode) Color.Gray else Color.DarkGray
|
||||
val barColor by animateColorAsState(
|
||||
targetValue = if (isDraggingScrollbar) activeThemeColor else idleColor,
|
||||
label = "sharedPdfScrollbarColor"
|
||||
)
|
||||
val scrollbarIdleWidth = 4.dp
|
||||
val scrollbarActiveWidth = 8.dp
|
||||
val barWidth by animateDpAsState(
|
||||
targetValue = if (isDraggingScrollbar) scrollbarActiveWidth else scrollbarIdleWidth,
|
||||
label = "sharedPdfScrollbarWidth"
|
||||
)
|
||||
val scrollbarIdleHeight = 40.dp
|
||||
val scrollbarActiveHeight = 60.dp
|
||||
val barHeight by animateDpAsState(
|
||||
targetValue = if (isDraggingScrollbar) scrollbarActiveHeight else scrollbarIdleHeight,
|
||||
label = "sharedPdfScrollbarHeight"
|
||||
)
|
||||
val currentScrollbarTrackHeight by rememberUpdatedState(scrollbarTrackHeight)
|
||||
val currentScrollbarContentHeight by rememberUpdatedState(state.contentHeightPx)
|
||||
val currentScrollbarViewportHeight by rememberUpdatedState(state.viewportHeightPx)
|
||||
val currentScrollbarProgress by rememberUpdatedState(state.progress)
|
||||
val currentScrollbarActiveThumbHeight by rememberUpdatedState(with(density) { scrollbarActiveHeight.toPx() })
|
||||
val safeCurrentPage = if (pageCount > 0) currentPage.coerceIn(0, pageCount - 1) else 0
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxHeight()
|
||||
.width(48.dp)
|
||||
.padding(top = 12.dp, bottom = 12.dp)
|
||||
.onGloballyPositioned { coordinates ->
|
||||
scrollbarTrackHeight = coordinates.size.height.toFloat()
|
||||
}
|
||||
.pointerInput(listState) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
try {
|
||||
isDraggingScrollbar = true
|
||||
scrollbarVisible = true
|
||||
scrollInteractionTick += 1
|
||||
down.consume()
|
||||
|
||||
val initialContentHeight = currentScrollbarContentHeight
|
||||
val initialViewportHeight = currentScrollbarViewportHeight
|
||||
val initialMaxScroll = (initialContentHeight - initialViewportHeight).coerceAtLeast(0f)
|
||||
val initialTrackHeight = currentScrollbarTrackHeight.takeIf { it > 0f }
|
||||
?: initialViewportHeight
|
||||
val initialThumbHeight = currentScrollbarActiveThumbHeight.coerceAtLeast(1f)
|
||||
val initialTrackSpace = (initialTrackHeight - initialThumbHeight).coerceAtLeast(1f)
|
||||
val initialThumbTop = (initialTrackSpace * currentScrollbarProgress)
|
||||
.coerceIn(0f, initialTrackSpace)
|
||||
val downY = down.position.y.coerceIn(0f, initialTrackHeight)
|
||||
val thumbDragOffset = if (downY in initialThumbTop..(initialThumbTop + initialThumbHeight)) {
|
||||
downY - initialThumbTop
|
||||
} else {
|
||||
initialThumbHeight / 2f
|
||||
}
|
||||
var lastScrollPx = currentScrollbarProgress * initialMaxScroll
|
||||
|
||||
fun scrollToPointer(pointerY: Float) {
|
||||
val contentHeight = currentScrollbarContentHeight
|
||||
val viewportHeight = currentScrollbarViewportHeight
|
||||
val maxScroll = (contentHeight - viewportHeight).coerceAtLeast(0f)
|
||||
if (maxScroll <= 0f) return
|
||||
val trackHeight = currentScrollbarTrackHeight.takeIf { it > 0f }
|
||||
?: viewportHeight
|
||||
val thumbHeight = currentScrollbarActiveThumbHeight.coerceAtLeast(1f)
|
||||
val trackSpace = (trackHeight - thumbHeight).coerceAtLeast(1f)
|
||||
val targetThumbTop = (pointerY - thumbDragOffset).coerceIn(0f, trackSpace)
|
||||
val targetScrollPx = (targetThumbTop / trackSpace) * maxScroll
|
||||
val scrollDelta = targetScrollPx - lastScrollPx
|
||||
if (abs(scrollDelta) > 0.01f) {
|
||||
listState.dispatchRawDelta(scrollDelta)
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
lastScrollPx = targetScrollPx
|
||||
}
|
||||
|
||||
scrollToPointer(down.position.y)
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
if (change == null || !change.pressed) break
|
||||
|
||||
if (change.position.y != change.previousPosition.y) {
|
||||
change.consume()
|
||||
scrollToPointer(change.position.y)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isDraggingScrollbar = false
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
val thumbHeightPx = with(density) { barHeight.toPx() }
|
||||
val effectiveTrackHeight = scrollbarTrackHeight.takeIf { it > 0f } ?: state.viewportHeightPx
|
||||
val availableSpace = (effectiveTrackHeight - thumbHeightPx).coerceAtLeast(0f)
|
||||
val thumbY = (availableSpace * state.progress).coerceIn(0f, availableSpace)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(x = 0, y = thumbY.roundToInt()) }
|
||||
.align(Alignment.TopEnd)
|
||||
.wrapContentSize(align = Alignment.CenterEnd, unbounded = true)
|
||||
.alpha(scrollbarAlpha)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
AnimatedVisibility(
|
||||
visible = isDraggingScrollbar && pageCount > 0,
|
||||
enter = fadeIn() + slideInHorizontally { it / 2 },
|
||||
exit = fadeOut() + slideOutHorizontally { it / 2 }
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = activeThemeColor,
|
||||
shadowElevation = 4.dp,
|
||||
modifier = Modifier.padding(end = 12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "${safeCurrentPage + 1}/$pageCount",
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
modifier = Modifier
|
||||
.height(barHeight)
|
||||
.width(48.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = barWidth, height = barHeight)
|
||||
.background(barColor, RoundedCornerShape(999.dp))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.sharedAcceleratedLazyWheelScroll(
|
||||
listState: LazyListState,
|
||||
multiplier: Float = 4f
|
||||
): Modifier {
|
||||
val safeMultiplier = multiplier.coerceIn(1f, 12f)
|
||||
return pointerInput(listState, safeMultiplier) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Final)
|
||||
if (event.type != PointerEventType.Scroll) continue
|
||||
val scrollDelta = event.changes.fold(0f) { total, change ->
|
||||
val delta = change.scrollDelta
|
||||
total + if (abs(delta.y) >= abs(delta.x)) delta.y else delta.x
|
||||
}
|
||||
if (abs(scrollDelta) > 0.01f) {
|
||||
val adaptiveMultiplier = when {
|
||||
abs(scrollDelta) < 1f -> 24f
|
||||
abs(scrollDelta) < 8f -> 10f
|
||||
else -> safeMultiplier
|
||||
}
|
||||
listState.dispatchRawDelta(scrollDelta * (adaptiveMultiplier - 1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,379 @@
|
|||
package org.dueattendant149.bookreader.shared.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowLeft
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.MyLocation
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.SkipNext
|
||||
import androidx.compose.material.icons.filled.SkipPrevious
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.dueattendant149.bookreader.shared.ReaderAiByokSettings
|
||||
import org.dueattendant149.bookreader.shared.ReaderCloudTtsState
|
||||
import org.dueattendant149.bookreader.shared.ReaderTtsReadScope
|
||||
import org.dueattendant149.bookreader.shared.readerCloudTtsControlsModel
|
||||
import org.dueattendant149.bookreader.shared.readerCloudTtsVoiceById
|
||||
|
||||
@Composable
|
||||
fun SharedReaderTtsOverlayControls(
|
||||
settings: ReaderAiByokSettings,
|
||||
cloudTts: ReaderCloudTtsState,
|
||||
credits: Int,
|
||||
showCredits: Boolean,
|
||||
isCollapsed: Boolean,
|
||||
onCollapseChange: (Boolean) -> Unit,
|
||||
onPauseResume: () -> Unit,
|
||||
onSkipPrevious: () -> Unit,
|
||||
onSkipNext: () -> Unit,
|
||||
onLocateCurrentChunk: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val sanitized = settings.sanitized()
|
||||
val controls = readerCloudTtsControlsModel(cloudTts)
|
||||
if (!controls.isVisible) return
|
||||
|
||||
val voice = remember(sanitized.ttsSpeakerId) { readerCloudTtsVoiceById(sanitized.ttsSpeakerId) }
|
||||
val progress = cloudTts.progress
|
||||
val chunk = progress.currentChunk
|
||||
val chunkLabel = if (progress.currentChunkIndex >= 0 && progress.chunks.isNotEmpty()) {
|
||||
readerString(
|
||||
"desktop_tts_chunk_count",
|
||||
"Part %1\$d/%2\$d",
|
||||
progress.currentChunkIndex + 1,
|
||||
progress.chunks.size
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val progressFraction = if (progress.currentChunkIndex >= 0 && progress.chunks.isNotEmpty()) {
|
||||
((progress.currentChunkIndex + 1).toFloat() / progress.chunks.size).coerceIn(0f, 1f)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val title = when {
|
||||
cloudTts.isLoading -> readerString("desktop_preparing_audio", "Preparing audio")
|
||||
cloudTts.isPaused -> readerString("desktop_paused", "Paused")
|
||||
cloudTts.isPlaying -> readerString("label_reading", "Reading")
|
||||
else -> readerString("action_read_aloud", "Read aloud")
|
||||
}
|
||||
val chapterLabel = chunk?.chapterTitle
|
||||
?.lineSequence()
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotBlank() }
|
||||
?.joinToString(" - ")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val statusLine = cloudTts.errorMessage
|
||||
?: progress.currentPositionLabel
|
||||
?: cloudTts.statusMessage
|
||||
?: chapterLabel
|
||||
?: voice?.let { "${it.name}: ${it.description}" }
|
||||
?: ""
|
||||
val scopeLabel = readerTtsScopeLabel(progress.scope)
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f)),
|
||||
modifier = modifier.widthIn(max = 560.dp).animateContentSize()
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = isCollapsed,
|
||||
transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
|
||||
label = "SharedReaderTtsOverlay"
|
||||
) { collapsed ->
|
||||
if (collapsed) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { onCollapseChange(false) },
|
||||
modifier = Modifier.size(36.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.KeyboardArrowLeft,
|
||||
contentDescription = readerString("content_desc_expand", "Expand"),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.widthIn(max = 220.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp)
|
||||
) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
chunkLabel ?: scopeLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
SharedReaderTtsPlayPauseButton(
|
||||
isPlaying = cloudTts.isPlaying,
|
||||
isLoading = cloudTts.isLoading,
|
||||
enabled = controls.canPauseResume,
|
||||
size = 36,
|
||||
iconSize = 20,
|
||||
onClick = onPauseResume
|
||||
)
|
||||
IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = readerString("content_desc_stop_tts", "Stop read aloud"),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (statusLine.isNotBlank()) {
|
||||
Text(
|
||||
statusLine,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (cloudTts.errorMessage != null) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
IconButton(
|
||||
enabled = controls.canLocateCurrentChunk,
|
||||
onClick = onLocateCurrentChunk,
|
||||
modifier = Modifier.size(34.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.MyLocation,
|
||||
contentDescription = readerString("desktop_locate_current_tts", "Locate current reading"),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { onCollapseChange(true) }, modifier = Modifier.size(34.dp)) {
|
||||
Icon(
|
||||
Icons.Default.KeyboardArrowRight,
|
||||
contentDescription = readerString("content_desc_collapse", "Collapse"),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onClose, modifier = Modifier.size(34.dp)) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = readerString("content_desc_stop_tts", "Stop read aloud"),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
SharedReaderTtsPill(readerString("tts_mode_cloud_ai", "Cloud AI"))
|
||||
voice?.let { SharedReaderTtsPill(it.name) }
|
||||
if (showCredits) {
|
||||
SharedReaderTtsPill(readerString("credits_count", "%1\$d credits", credits))
|
||||
}
|
||||
SharedReaderTtsPill(chunkLabel ?: scopeLabel)
|
||||
}
|
||||
|
||||
progressFraction?.let { fraction ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(3.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.18f))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(fraction)
|
||||
.height(3.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
IconButton(
|
||||
enabled = controls.canSkipPrevious,
|
||||
onClick = onSkipPrevious,
|
||||
modifier = Modifier.size(44.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.SkipPrevious,
|
||||
contentDescription = readerString("content_desc_tts_previous_chunk", "Previous chunk"),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
SharedReaderTtsPlayPauseButton(
|
||||
isPlaying = cloudTts.isPlaying,
|
||||
isLoading = cloudTts.isLoading,
|
||||
enabled = controls.canPauseResume,
|
||||
size = 56,
|
||||
iconSize = 28,
|
||||
onClick = onPauseResume
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
IconButton(
|
||||
enabled = controls.canSkipNext,
|
||||
onClick = onSkipNext,
|
||||
modifier = Modifier.size(44.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.SkipNext,
|
||||
contentDescription = readerString("content_desc_tts_next_chunk", "Next chunk"),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedReaderTtsPill(text: String) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedReaderTtsPlayPauseButton(
|
||||
isPlaying: Boolean,
|
||||
isLoading: Boolean,
|
||||
enabled: Boolean,
|
||||
size: Int,
|
||||
iconSize: Int,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Box(modifier = Modifier.size(size.dp), contentAlignment = Alignment.Center) {
|
||||
FilledIconButton(
|
||||
enabled = enabled,
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(size.dp),
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
|
||||
contentColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||
contentDescription = readerString("content_desc_play_pause", "Play or pause"),
|
||||
modifier = Modifier.size(iconSize.dp)
|
||||
)
|
||||
}
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(size.dp),
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
|
||||
strokeWidth = if (size >= 56) 3.dp else 2.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun readerTtsScopeLabel(scope: ReaderTtsReadScope): String {
|
||||
return when (scope) {
|
||||
ReaderTtsReadScope.PAGE -> readerString("desktop_page", "Page")
|
||||
ReaderTtsReadScope.CHAPTER -> readerString("chapter", "Chapter")
|
||||
ReaderTtsReadScope.BOOK -> readerString("desktop_from_here", "From here")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue