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:
Atte149 2026-06-24 10:36:09 +03:00
parent ab9a2dcfeb
commit 0d9439e8c3
323 changed files with 74 additions and 50598 deletions

View 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
}
}

View file

@ -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

View file

@ -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()
}

View file

@ -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
}
}
}

View file

@ -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>
)

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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()
}

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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()
)

View file

@ -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"
}

View file

@ -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()
}
}

View file

@ -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
}
}

View file

@ -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

View file

@ -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
}
}

View file

@ -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
)

View file

@ -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 }
}

View file

@ -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)
}

View file

@ -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 }
}

View file

@ -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

View file

@ -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
}

View file

@ -0,0 +1,3 @@
package org.dueattendant149.bookreader.shared
fun currentTimestamp(): Long = System.currentTimeMillis()

View file

@ -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")
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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() }
}

View file

@ -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
)

View file

@ -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
}
}

View file

@ -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))
}
}

View file

@ -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
}
}

View file

@ -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()
}
}

View file

@ -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()
}

View file

@ -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
)
}

View file

@ -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
)
}

View file

@ -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
)
}
}

View file

@ -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)
}
}

View file

@ -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"
)
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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
}
}
}

View file

@ -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() }
}
}

View file

@ -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)
}
}

View file

@ -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?
}

View file

@ -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"
)
}
}

View file

@ -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()
}
}

View file

@ -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)
}
}

View file

@ -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()
}

View file

@ -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
}
}
}

View file

@ -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
}
}

View file

@ -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
)
}

View file

@ -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
}

View file

@ -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()
}

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;")

View file

@ -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
)
}

View file

@ -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)
}

View file

@ -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("|")
}

View file

@ -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
}

View file

@ -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()
}

View file

@ -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("\"", "\\\"")
}

View file

@ -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()
}
}

View file

@ -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()
}
}
}

View file

@ -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")
}
}

View file

@ -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())
}
}

View file

@ -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() }
}

View file

@ -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("&nbsp;", " ")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.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"""), "")
}
}

View file

@ -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)
}
}

View file

@ -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
)
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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
)
}

View file

@ -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
}

View file

@ -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
}
}
}
}

View file

@ -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)
}
}

View file

@ -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()})"
}

View file

@ -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
)
}

View file

@ -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))
}
}
}
}
}

View file

@ -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")
}
}

View file

@ -0,0 +1,182 @@
package org.dueattendant149.bookreader.shared.ui
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
data class SharedSelectionMenuViewport(
val width: Int,
val height: Int
)
data class SharedSelectionMenuSize(
val width: Int,
val height: Int
)
data class SharedSelectionMenuRect(
val left: Float,
val top: Float,
val right: Float,
val bottom: Float
)
enum class SharedSelectionMenuPlacement {
ABOVE,
BELOW,
LEFT,
RIGHT,
FALLBACK
}
data class SharedSelectionMenuPlacementResult(
val x: Int,
val y: Int,
val placement: SharedSelectionMenuPlacement
)
fun sharedSelectionMenuPlacement(
viewport: SharedSelectionMenuViewport,
popup: SharedSelectionMenuSize,
selection: SharedSelectionMenuRect,
marginPx: Float,
gapPx: Float
): SharedSelectionMenuPlacementResult {
val viewportWidth = viewport.width.coerceAtLeast(0).toFloat()
val viewportHeight = viewport.height.coerceAtLeast(0).toFloat()
val popupWidth = popup.width.coerceAtLeast(0).toFloat()
val popupHeight = popup.height.coerceAtLeast(0).toFloat()
val margin = marginPx.coerceAtLeast(0f)
val gap = gapPx.coerceAtLeast(0f)
val keepClear = selection.normalized().clampedToViewport(viewportWidth, viewportHeight)
fun centeredX(): Float = keepClear.centerX - popupWidth / 2f
fun centeredY(): Float = keepClear.centerY - popupHeight / 2f
fun clamped(x: Float, y: Float): SharedSelectionMenuCandidate {
return SharedSelectionMenuCandidate(
x = clampStart(x, popupWidth, viewportWidth, margin),
y = clampStart(y, popupHeight, viewportHeight, margin)
)
}
val above = clamped(centeredX(), keepClear.top - gap - popupHeight)
if (above.y + popupHeight <= keepClear.top - gap && above.y >= margin) {
return above.toResult(SharedSelectionMenuPlacement.ABOVE)
}
val below = clamped(centeredX(), keepClear.bottom + gap)
if (below.y >= keepClear.bottom + gap && below.y + popupHeight <= viewportHeight - margin) {
return below.toResult(SharedSelectionMenuPlacement.BELOW)
}
val leftSpace = keepClear.left - gap - margin
val rightSpace = viewportWidth - keepClear.right - gap - margin
val sideCandidates = if (rightSpace >= leftSpace) {
listOf(
SharedSelectionMenuPlacement.RIGHT to clamped(keepClear.right + gap, centeredY()),
SharedSelectionMenuPlacement.LEFT to clamped(keepClear.left - gap - popupWidth, centeredY())
)
} else {
listOf(
SharedSelectionMenuPlacement.LEFT to clamped(keepClear.left - gap - popupWidth, centeredY()),
SharedSelectionMenuPlacement.RIGHT to clamped(keepClear.right + gap, centeredY())
)
}
sideCandidates.forEach { (placement, candidate) ->
val fitsHorizontally = when (placement) {
SharedSelectionMenuPlacement.LEFT -> candidate.x + popupWidth <= keepClear.left - gap
SharedSelectionMenuPlacement.RIGHT -> candidate.x >= keepClear.right + gap
else -> false
}
if (fitsHorizontally && candidate.y >= margin && candidate.y + popupHeight <= viewportHeight - margin) {
return candidate.toResult(placement)
}
}
return listOf(
above,
below,
sideCandidates[0].second,
sideCandidates[1].second
).minWith(
compareBy<SharedSelectionMenuCandidate> { candidate ->
candidate.overlapAreaWith(keepClear, popupWidth, popupHeight)
}.thenBy { candidate ->
candidate.distanceFrom(keepClear, popupWidth, popupHeight)
}
).toResult(SharedSelectionMenuPlacement.FALLBACK)
}
private data class SharedSelectionMenuCandidate(
val x: Float,
val y: Float
) {
fun toResult(placement: SharedSelectionMenuPlacement): SharedSelectionMenuPlacementResult {
return SharedSelectionMenuPlacementResult(
x = x.roundToInt(),
y = y.roundToInt(),
placement = placement
)
}
fun overlapAreaWith(
rect: SharedSelectionMenuRect,
width: Float,
height: Float
): Float {
val overlapWidth = min(x + width, rect.right) - max(x, rect.left)
val overlapHeight = min(y + height, rect.bottom) - max(y, rect.top)
return overlapWidth.coerceAtLeast(0f) * overlapHeight.coerceAtLeast(0f)
}
fun distanceFrom(
rect: SharedSelectionMenuRect,
width: Float,
height: Float
): Float {
val dx = maxOf(rect.left - (x + width), x - rect.right, 0f)
val dy = maxOf(rect.top - (y + height), y - rect.bottom, 0f)
return dx * dx + dy * dy
}
}
private fun SharedSelectionMenuRect.normalized(): SharedSelectionMenuRect {
return SharedSelectionMenuRect(
left = min(left, right),
top = min(top, bottom),
right = max(left, right),
bottom = max(top, bottom)
)
}
private val SharedSelectionMenuRect.centerX: Float
get() = (left + right) / 2f
private val SharedSelectionMenuRect.centerY: Float
get() = (top + bottom) / 2f
private fun SharedSelectionMenuRect.clampedToViewport(
viewportWidth: Float,
viewportHeight: Float
): SharedSelectionMenuRect {
return SharedSelectionMenuRect(
left = left.coerceIn(0f, viewportWidth),
top = top.coerceIn(0f, viewportHeight),
right = right.coerceIn(0f, viewportWidth),
bottom = bottom.coerceIn(0f, viewportHeight)
).normalized()
}
private fun clampStart(
preferred: Float,
popupSize: Float,
viewportSize: Float,
margin: Float
): Float {
if (viewportSize <= 0f || popupSize <= 0f) return 0f
if (viewportSize <= popupSize) return 0f
val maxStart = viewportSize - popupSize
val min = margin.coerceIn(0f, maxStart)
val max = (viewportSize - popupSize - margin).coerceAtLeast(min)
return preferred.coerceIn(min, max)
}

Some files were not shown because too many files have changed in this diff Show more