* Add performance and stylus debugging logs

* Refactor and decouple UI models from `MainViewModel`

* Refactor library state management and projection logic

* Implement desktop shell using Compose Multiplatform

* Implement desktop shell using Compose Multiplatform

* Implement desktop shell using Compose Multiplatform

* Introduce ReaderEngine and enhance EPUB reader features in windows app

* Move core paginated reader logic to a Kotlin Multiplatform `shared` module and introduce experimental desktop support.

* Implement PDF rendering and text extraction for desktop using Pdfium

* Add `NonReaderScreens.kt` and UI dependencies

* Refactor and centralize library state management and models to improve cross-platform consistency

* Implement JSON persistence for desktop library and enhance library management features including shelf CRUD, tagging, and metadata editing

* Implement PDF annotation system and enhanced zoom controls for the desktop viewer

* Implement WebView-based EPUB rendering for desktop using CEF and embedded resources

* Optimize UI state projection, navigation state handling, and main screen pager performance

* Implement Bring Your Own Key (BYOK) support for AI features in OSS version

* Support Gemini-based Cloud TTS with BYOK support for OSS builds

* Refactor table cell image sizing in `PaginatedReader` and improve `MobiParser` native library loading and error handling.

* crash fixes

* Enhance navigation stability with lifecycle-aware safety checks and update `navigation-compose` to 2.9.6

* Implement dynamic bottom padding for the page info bar to account for device rounded corners

* Implement bidirectional jump history navigation and replace the jump-back pill with a dedicated `PdfJumpHistoryBar`

* Optimize PDF tiling performance and refine pan-and-fling gesture handling

* Implement customizable toolbars with drag-and-drop reordering and placement for PDF and EPUB readers

* Updated UI for customize toolbar

* Refine drag-and-drop reordering and section assignment for PDF and EPUB reader controls

* restructure PDF viewer UI component hierarchy to fix verifier crash

* Implement separate text dimming factors for light and dark themes

* Synchronize Pdfium access and improve resource lifecycle safety across Kotlin and native layers

* Enhance image alignment in paginated and EPUB readers through anchor detection and style-based positioning

* Centralize file type resolution logic and implement HTML sanitization during import

* Introduce vertical margin customization and configurable progress bar positioning

* texture support in epub reader

* Enhance TTS session management, progress tracking, and diagnostic logging

* Optimize library state projection and folder synchronization performance by refactoring collection lookups and refining metadata extraction logic.

* Refine TTS page mapping for PDF and overhaul TTS control UI

* Implement natural session completion logic in `TtsPlaybackManager` for cloud tts

* Replace Snackbar with `CustomTopBanner` for notifications in `PdfViewerScreen`

* Refine TTS playback continuity across PDF pages and improve state management for session transitions

* Implement global texture transparency and enhance textured theme support across PDF and EPUB readers.

* Update reader themes and improve texture rendering in page animations, EPUB UI, and immersive mode

* Add Support Project screen

* Optimize library performance via projection caching, batch database updates, and scoped folder synchronization.

* Enhance folder synchronization with fallback query mechanisms and refactor annotation sidecar importing logic

* Bump version to 1.0.47 (51)
This commit is contained in:
Aryan 2026-05-04 21:55:38 +05:30 committed by GitHub
parent f42de6b462
commit d7a9cae9e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
126 changed files with 15287 additions and 3154 deletions

49
shared/build.gradle.kts Normal file
View file

@ -0,0 +1,49 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
id("com.android.library")
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.compose.multiplatform)
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.20"
}
kotlin {
androidTarget()
jvm("desktop")
jvmToolchain(21)
sourceSets {
val commonMain by getting
val androidMain by getting
val desktopMain by getting
val readerJvmMain by creating {
dependsOn(commonMain)
dependencies {
implementation("org.jsoup:jsoup:1.17.2")
}
}
androidMain.dependsOn(readerJvmMain)
desktopMain.dependsOn(readerJvmMain)
commonMain.dependencies {
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.materialIconsExtended)
implementation(compose.ui)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:1.7.3")
}
commonTest.dependencies {
implementation(kotlin("test"))
}
}
}
android {
namespace = "com.aryan.reader.shared"
compileSdk = 36
defaultConfig {
minSdk = 26
}
}

View file

@ -0,0 +1,3 @@
package com.aryan.reader.shared
actual fun currentTimestamp(): Long = System.currentTimeMillis()

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,39 @@
package com.aryan.reader.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,412 @@
/*
* 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 com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.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 com.aryan.reader.paginatedreader.serialization.AnnotatedStringSerializer
import com.aryan.reader.paginatedreader.serialization.ColorSerializer
import com.aryan.reader.paginatedreader.serialization.DpSerializer
import com.aryan.reader.paginatedreader.serialization.ParagraphStyleSerializer
import com.aryan.reader.paginatedreader.serialization.SpanStyleSerializer
import com.aryan.reader.paginatedreader.serialization.TextAlignSerializer
import com.aryan.reader.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
) {
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
)
}
}
@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
) {
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
)
}
}
@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
)
@Serializable
data class FontFaceInfo(
@ProtoNumber(1) val fontFamily: String,
@ProtoNumber(2) val src: String,
@ProtoNumber(3) @Serializable(with = com.aryan.reader.paginatedreader.serialization.FontWeightSerializer::class) val fontWeight: FontWeight?,
@ProtoNumber(4) @Serializable(with = com.aryan.reader.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()
) {
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
)
}
fun toFlatList(): List<CssRule> {
return 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 com.aryan.reader.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 com.aryan.reader.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 com.aryan.reader.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 com.aryan.reader.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 com.aryan.reader.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,38 @@
package com.aryan.reader.shared
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 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 GoToProgress(val progress: Float) : ReaderAction
data class GoToChapter(val chapterIndex: Int) : ReaderAction
data class SearchChanged(val query: String) : ReaderAction
data object NextSearchResult : ReaderAction
data object PreviousSearchResult : ReaderAction
data object ToggleBookmark : ReaderAction
data class RenderModeChanged(val renderMode: RenderMode) : ReaderAction
data class ThemeChanged(val theme: ReaderTheme) : ReaderAction
data class FormatChanged(val settings: FormatSettings) : 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 SyncEnabledChanged(val enabled: Boolean) : AppAction
data class FolderSyncEnabledChanged(val enabled: Boolean) : AppAction
}

View file

@ -0,0 +1,122 @@
package com.aryan.reader.shared
import androidx.compose.ui.graphics.Color
data class BannerMessage(
val message: String,
val isError: Boolean = false,
val isPersistent: Boolean = false
)
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)
}
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 = false,
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 appThemeMode: AppThemeMode = AppThemeMode.SYSTEM,
val appContrastOption: AppContrastOption = AppContrastOption.STANDARD,
val appTextDimFactorLight: Float = 1.0f,
val appTextDimFactorDark: Float = 1.0f,
val appSeedColor: Color? = null,
val customAppThemes: List<CustomAppTheme> = emptyList(),
val allTags: List<Tag> = emptyList(),
val showTagSelectionDialogFor: Set<String> = emptySet()
)

View file

@ -0,0 +1,139 @@
package com.aryan.reader.shared
enum class FileType {
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT, UNKNOWN
}
val PDF_VIEWER_FILE_TYPES = setOf(FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7)
val EPUB_READER_FILE_TYPES = setOf(
FileType.EPUB,
FileType.MOBI,
FileType.MD,
FileType.TXT,
FileType.HTML,
FileType.FB2,
FileType.DOCX,
FileType.ODT,
FileType.FODT
)
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
}
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> = FileType.entries.toSet()
)
data class BookItem(
val id: String,
val path: String?,
val type: FileType,
val displayName: String,
val timestamp: Long,
val title: String? = null,
val author: String? = null,
val progressPercentage: Float? = null,
val isRecent: Boolean = true,
val fileSize: Long = 0L,
val sourceFolder: String? = null,
val seriesName: String? = null,
val seriesIndex: Double? = null,
val tags: List<Tag> = emptyList()
)
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
)
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,173 @@
package com.aryan.reader.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 now = currentTimestamp()
val existingIds = state.books.mapTo(mutableSetOf()) { it.id }
val imported = files.mapIndexedNotNull { index, file ->
val id = file.path ?: file.name
if (!existingIds.add(id)) {
null
} else {
BookItem(
id = id,
path = file.path,
type = file.name.toFileType(),
displayName = file.name,
timestamp = now + index,
title = file.name.substringBeforeLast('.'),
fileSize = file.size,
sourceFolder = file.path?.parentPath()
)
}
}
return state.copy(
books = imported + state.books,
message = if (imported.isEmpty()) "Those files are already in the desktop library." else "Imported ${imported.size} file(s). Reader support comes later."
)
}
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.sortedBy { 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 = filters.sourceFolders.isEmpty() || book.sourceFolder in 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.parentPath(): String? {
val normalized = replace('\\', '/')
val parent = normalized.substringBeforeLast('/', missingDelimiterValue = "")
return parent.ifBlank { null }
}
private fun String.folderDisplayName(): String {
return replace('\\', '/').trimEnd('/').substringAfterLast('/').ifBlank { "Local Folder" }
}
data class ImportedFile(
val name: String,
val path: String?,
val size: Long
)
expect fun currentTimestamp(): Long
fun String.toFileType(): FileType {
return when (substringAfterLast('.', "").lowercase()) {
"pdf" -> FileType.PDF
"epub" -> FileType.EPUB
"mobi" -> FileType.MOBI
"md" -> FileType.MD
"txt" -> FileType.TXT
"html", "htm" -> FileType.HTML
"fb2" -> FileType.FB2
"cbz" -> FileType.CBZ
"cbr" -> FileType.CBR
"cb7" -> FileType.CB7
"docx" -> FileType.DOCX
"odt" -> FileType.ODT
"fodt" -> FileType.FODT
else -> FileType.UNKNOWN
}
}

View file

@ -0,0 +1,324 @@
package com.aryan.reader.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 queried = filterBySearch(allLibraryBooks, current.searchQuery)
val filtered = applyLibraryFilters(queried, current.libraryFilters)
val sortedLibraryBooks = sortBooks(filtered, current.sortOrder)
val visibleRecentBooks = sortBooks(
allLibraryBooks.filter { it.isRecent },
current.sortOrder
).take(if (current.recentFilesLimit > 0) current.recentFilesLimit else Int.MAX_VALUE)
val openTabs = current.openTabIds.mapNotNull { tabId -> allLibraryBooks.find { it.id == tabId } }
val shelfProjection = buildShelves(
allLibraryBooks = allLibraryBooks,
shelfRecords = input.shelfRecords,
shelfRefs = input.shelfRefs,
tags = input.tags,
sortOrder = current.sortOrder,
syncedFolders = current.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,
booksAvailableForAdding = booksAvailableForAdding,
allTags = input.tags
)
}
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 ->
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 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 = filters.sourceFolders.isEmpty() || book.sourceFolder in 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.sortedBy { 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 existingIds = rawLibraryBooks.mapTo(mutableSetOf()) { it.id }
val imported = files.mapIndexedNotNull { index, file ->
val id = file.localPath ?: file.uriString ?: file.name
if (!existingIds.add(id)) {
null
} else {
BookItem(
id = id,
path = file.localPath ?: file.uriString,
type = file.name.toFileType(),
displayName = file.name,
timestamp = now + index,
title = file.name.substringBeforeLast('.'),
fileSize = file.size,
sourceFolder = file.localPath?.parentPath()
)
}
}
return copy(
rawLibraryBooks = imported + rawLibraryBooks,
bannerMessage = BannerMessage(
if (imported.isEmpty()) {
"Those files are already in the library."
} else {
"Imported ${imported.size} file(s)."
}
)
)
}
private fun String.parentPath(): String? {
val normalized = replace('\\', '/')
val parent = normalized.substringBeforeLast('/', missingDelimiterValue = "")
return parent.ifBlank { null }
}

View file

@ -0,0 +1,81 @@
package com.aryan.reader.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,51 @@
package com.aryan.reader.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
)
enum class HighlightColor(val id: String, val color: Color, val cssClass: String) {
YELLOW("yellow", Color(0xFFFBC02D), "user-highlight-yellow"),
GREEN("green", Color(0xFF388E3C), "user-highlight-green"),
BLUE("blue", Color(0xFF1976D2), "user-highlight-blue"),
RED("red", Color(0xFFD32F2F), "user-highlight-red"),
PURPLE("purple", Color(0xFF7B1FA2), "user-highlight-purple"),
ORANGE("orange", Color(0xFFF57C00), "user-highlight-orange"),
CYAN("cyan", Color(0xFF0097A7), "user-highlight-cyan"),
MAGENTA("magenta", Color(0xFFC2185B), "user-highlight-magenta"),
LIME("lime", Color(0xFFAFB42B), "user-highlight-lime"),
PINK("pink", Color(0xFFE91E63), "user-highlight-pink"),
TEAL("teal", Color(0xFF00796B), "user-highlight-teal"),
INDIGO("indigo", Color(0xFF303F9F), "user-highlight-indigo"),
BLACK("black", Color(0xFF424242), "user-highlight-black"),
WHITE("white", Color(0xFFF5F5F5), "user-highlight-white")
}
data class UserHighlight(
val id: String,
val cfi: String,
val text: String,
val color: HighlightColor,
val chapterIndex: Int,
val note: String? = null
)
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,67 @@
package com.aryan.reader.shared
import androidx.compose.ui.graphics.Color
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"),
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")
}
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
)
enum class ReaderTexture(val id: String, val displayName: String) {
PAPER("paper", "Paper"),
CANVAS("canvas", "Canvas"),
EINK("eink", "E-Ink"),
SLATE("slate", "Slate")
}
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
)
val BuiltInReaderThemes = listOf(
ReaderTheme("system", "System", Color.Unspecified, Color.Unspecified, false),
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)
)

View file

@ -0,0 +1,29 @@
package com.aryan.reader.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
)
data class RecapResult(
val recap: String? = null,
val error: String? = null,
val cost: Double? = null,
val freeRemaining: Int? = null
)

View file

@ -0,0 +1,58 @@
package com.aryan.reader.shared
import kotlinx.coroutines.flow.Flow
data class ImportedBookFile(
val name: String,
val uriString: String?,
val localPath: String?,
val size: Long
)
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 summarize(text: String): SummarizationResult
suspend fun recap(textBeforeCurrentLocation: String): RecapResult
}
interface TtsAdapter {
val isAvailable: Boolean
suspend fun speak(text: String)
suspend fun stop()
}

View file

@ -0,0 +1,66 @@
package com.aryan.reader.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 com.aryan.reader.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,48 @@
package com.aryan.reader.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
}
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,62 @@
package com.aryan.reader.shared
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)
}
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)
}
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.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.SyncEnabledChanged -> copy(isSyncEnabled = action.enabled)
is AppAction.FolderSyncEnabledChanged -> copy(isFolderSyncEnabled = action.enabled)
}
}

View file

@ -0,0 +1,140 @@
package com.aryan.reader.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
}
enum class PdfInkTool {
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 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 text: String = "",
val colorArgb: Int,
val backgroundArgb: Int = 0x00FFFFFF,
val strokeWidth: Float = 2f,
val fontSize: Float = 16f,
val isBold: Boolean = false,
val isItalic: Boolean = false,
val createdAt: Long = 0L
)
data class PdfToolConfig(
val colorArgb: Int,
val strokeWidth: Float
)
object SharedPdfAnnotationDefaults {
val penPalette: List<Int> = listOf(
0xFF111111.toInt(),
0xFFD32F2F.toInt(),
0xFF1976D2.toInt(),
0xFF388E3C.toInt(),
0xFFFFFFFF.toInt()
)
val highlighterPalette: List<Int> = listOf(
0x8CFF9800.toInt(),
0x8CFFEB3B.toInt(),
0x8C81C784.toInt(),
0x8C64B5F6.toInt(),
0x8CE1BEE7.toInt()
)
fun configFor(tool: PdfInkTool): PdfToolConfig {
return when (tool) {
PdfInkTool.PEN -> PdfToolConfig(0xFF111111.toInt(), 2.5f)
PdfInkTool.FOUNTAIN_PEN -> PdfToolConfig(0xFF111111.toInt(), 3.5f)
PdfInkTool.PENCIL -> PdfToolConfig(0xFF616161.toInt(), 1.8f)
PdfInkTool.HIGHLIGHTER -> PdfToolConfig(0x8CFFEB3B.toInt(), 12f)
PdfInkTool.HIGHLIGHTER_ROUND -> PdfToolConfig(0x8CFF9800.toInt(), 16f)
PdfInkTool.ERASER -> PdfToolConfig(0x00000000, 18f)
PdfInkTool.TEXT -> PdfToolConfig(0xFF111111.toInt(), 1f)
}
}
}
@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())
}
}
}
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,63 @@
package com.aryan.reader.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,163 @@
package com.aryan.reader.shared.reader
data class ReaderBookmark(
val id: String,
val pageIndex: Int,
val chapterTitle: String,
val preview: String
)
data class ReaderSearchResult(
val pageIndex: Int,
val chapterTitle: String,
val preview: String
)
data class ReaderSessionState(
val reader: PaginatedReaderState,
val bookmarks: List<ReaderBookmark> = emptyList(),
val searchQuery: String = "",
val searchResults: List<ReaderSearchResult> = emptyList(),
val activeSearchResultIndex: Int = -1
) {
val currentBookmark: ReaderBookmark?
get() = bookmarks.firstOrNull { it.pageIndex == reader.currentPageIndex }
val activeSearchResult: ReaderSearchResult?
get() = searchResults.getOrNull(activeSearchResultIndex)
}
class ReaderEngine(
private val paginator: SimplePaginator = SimplePaginator()
) {
fun createSession(
book: SharedEpubBook,
settings: ReaderSettings = ReaderSettings()
): ReaderSessionState {
return ReaderSessionState(
reader = PaginatedReaderState(
book = book,
pages = paginator.paginate(book, settings),
settings = settings
)
)
}
fun next(state: ReaderSessionState): ReaderSessionState {
if (!state.reader.canGoNext) return state
return state.copy(reader = state.reader.copy(currentPageIndex = state.reader.currentPageIndex + 1))
}
fun previous(state: ReaderSessionState): ReaderSessionState {
if (!state.reader.canGoPrevious) return state
return state.copy(reader = state.reader.copy(currentPageIndex = state.reader.currentPageIndex - 1))
}
fun goToPage(state: ReaderSessionState, pageIndex: Int): ReaderSessionState {
val target = pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0))
return state.copy(
reader = state.reader.copy(currentPageIndex = target),
activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == target }
)
}
fun goToProgress(state: ReaderSessionState, progress: Float): ReaderSessionState {
if (state.reader.pages.isEmpty()) return state
val target = ((state.reader.pages.lastIndex) * progress.coerceIn(0f, 1f)).toInt()
return goToPage(state, target)
}
fun goToChapter(state: ReaderSessionState, chapterIndex: Int): ReaderSessionState {
val pageIndex = state.reader.pages.indexOfFirst { it.chapterIndex == chapterIndex }
return if (pageIndex >= 0) goToPage(state, pageIndex) else state
}
fun updateSettings(state: ReaderSessionState, settings: ReaderSettings): ReaderSessionState {
return state.copy(reader = paginator.repaginate(state.reader, settings))
}
fun toggleBookmark(state: ReaderSessionState): ReaderSessionState {
val page = state.reader.currentPage ?: return state
val existing = state.bookmarks.firstOrNull { it.pageIndex == state.reader.currentPageIndex }
val updated = if (existing != null) {
state.bookmarks - existing
} else {
state.bookmarks + ReaderBookmark(
id = "${state.reader.book.id}_${state.reader.currentPageIndex}",
pageIndex = state.reader.currentPageIndex,
chapterTitle = page.chapterTitle,
preview = page.text.preview()
)
}
return state.copy(bookmarks = updated.sortedBy { it.pageIndex })
}
fun search(state: ReaderSessionState, query: String): ReaderSessionState {
val normalized = query.trim()
val results = if (normalized.isBlank()) {
emptyList()
} else {
state.reader.pages.mapNotNull { page ->
val index = page.text.indexOf(normalized, ignoreCase = true)
if (index < 0) {
null
} else {
ReaderSearchResult(
pageIndex = page.pageIndex,
chapterTitle = page.chapterTitle,
preview = page.text.previewAround(index, normalized.length)
)
}
}
}
val activeIndex = results.indexOfFirst { it.pageIndex >= state.reader.currentPageIndex }
.takeIf { it >= 0 }
?: if (results.isNotEmpty()) 0 else -1
val updated = state.copy(
searchQuery = query,
searchResults = results,
activeSearchResultIndex = activeIndex
)
return updated.activeSearchResult?.let { goToPage(updated, it.pageIndex) } ?: updated
}
fun nextSearchResult(state: ReaderSessionState): ReaderSessionState {
if (state.searchResults.isEmpty()) return state
val nextIndex = if (state.activeSearchResultIndex < state.searchResults.lastIndex) {
state.activeSearchResultIndex + 1
} else {
0
}
return state.copy(
reader = state.reader.copy(currentPageIndex = state.searchResults[nextIndex].pageIndex),
activeSearchResultIndex = nextIndex
)
}
fun previousSearchResult(state: ReaderSessionState): ReaderSessionState {
if (state.searchResults.isEmpty()) return state
val nextIndex = if (state.activeSearchResultIndex > 0) {
state.activeSearchResultIndex - 1
} else {
state.searchResults.lastIndex
}
return state.copy(
reader = state.reader.copy(currentPageIndex = state.searchResults[nextIndex].pageIndex),
activeSearchResultIndex = nextIndex
)
}
}
private fun String.preview(): String {
return trim()
.replace(Regex("\\s+"), " ")
.take(140)
}
private fun String.previewAround(index: Int, queryLength: Int): String {
val start = (index - 70).coerceAtLeast(0)
val end = (index + queryLength + 100).coerceAtMost(length)
val prefix = if (start > 0) "..." else ""
val suffix = if (end < length) "..." else ""
return prefix + substring(start, end).replace(Regex("\\s+"), " ").trim() + suffix
}

View file

@ -0,0 +1,219 @@
package com.aryan.reader.shared.reader
import com.aryan.reader.paginatedreader.SemanticBlock
import com.aryan.reader.paginatedreader.SemanticFlexContainer
import com.aryan.reader.paginatedreader.SemanticHeader
import com.aryan.reader.paginatedreader.SemanticImage
import com.aryan.reader.paginatedreader.SemanticList
import com.aryan.reader.paginatedreader.SemanticListItem
import com.aryan.reader.paginatedreader.SemanticMath
import com.aryan.reader.paginatedreader.SemanticParagraph
import com.aryan.reader.paginatedreader.SemanticSpacer
import com.aryan.reader.paginatedreader.SemanticTable
import com.aryan.reader.paginatedreader.SemanticTextBlock
import com.aryan.reader.paginatedreader.SemanticWrappingBlock
object ReaderHtmlDocumentBuilder {
fun verticalDocument(book: SharedEpubBook, settings: ReaderSettings, searchQuery: String = ""): String {
val body = book.chapters.mapIndexed { index, chapter ->
"""
<section class="chapter" id="chapter-$index">
<h1 class="chapter-title">${chapter.title.escapeHtml()}</h1>
${chapter.toHtml(searchQuery)}
</section>
""".trimIndent()
}.joinToString("\n")
return document(
title = book.title,
settings = settings,
bookCss = book.css.values.joinToString("\n"),
body = body,
searchQuery = searchQuery
)
}
fun pageDocument(book: SharedEpubBook, page: ReaderPage?, settings: ReaderSettings, searchQuery: String = ""): String {
val chapter = page?.let { book.chapters.getOrNull(it.chapterIndex) }
val body = if (page == null || chapter == null) {
"<section class=\"page\"></section>"
} else {
val blocks = chapter.semanticBlocks
.filter { block ->
val start = (block as? SemanticTextBlock)?.startCharOffsetInSource ?: return@filter false
start in page.startOffset..page.endOffset
}
.takeIf { it.isNotEmpty() }
?.joinToString("\n") { it.toHtml(searchQuery) }
?: page.text.textToParagraphHtml(searchQuery)
"""
<section class="page">
<h1 class="chapter-title">${page.chapterTitle.escapeHtml()}</h1>
$blocks
</section>
""".trimIndent()
}
return document(
title = book.title,
settings = settings,
bookCss = book.css.values.joinToString("\n"),
body = body,
searchQuery = searchQuery
)
}
private fun document(
title: String,
settings: ReaderSettings,
bookCss: String,
body: String,
searchQuery: String
): String {
val bg = if (settings.darkMode) "#171A17" else "#FFFCF5"
val fg = if (settings.darkMode) "#E7E3D8" else "#24231F"
val highlight = if (settings.darkMode) "#675A00" else "#FFE36E"
val align = when (settings.textAlign) {
SharedReaderTextAlign.START -> "left"
SharedReaderTextAlign.JUSTIFY -> "justify"
SharedReaderTextAlign.CENTER -> "center"
}
val family = when (settings.fontFamily) {
"Serif" -> "Georgia, 'Times New Roman', serif"
"Sans" -> "Inter, Segoe UI, Arial, sans-serif"
"Mono" -> "'Roboto Mono', Consolas, monospace"
else -> "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
}
return """
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${title.escapeHtml()}</title>
<style>
$bookCss
:root {
color-scheme: ${if (settings.darkMode) "dark" else "light"};
--reader-bg: $bg;
--reader-fg: $fg;
--reader-highlight: $highlight;
--reader-font-size: ${settings.fontSize}px;
--reader-line-height: ${settings.lineSpacing};
--reader-page-width: ${settings.pageWidth}px;
--reader-margin: ${settings.margin}px;
--reader-align: $align;
--reader-family: $family;
}
html, body {
min-height: 100%;
margin: 0;
background: var(--reader-bg);
color: var(--reader-fg);
font-family: var(--reader-family);
font-size: var(--reader-font-size);
line-height: var(--reader-line-height);
}
body {
box-sizing: border-box;
padding: var(--reader-margin);
overflow-wrap: anywhere;
}
.chapter, .page {
max-width: var(--reader-page-width);
margin: 0 auto 48px;
text-align: var(--reader-align);
}
.chapter-title {
text-align: left;
font-size: 1.55em;
line-height: 1.25;
margin: 0 0 1.1em;
}
p, blockquote, pre, ul, ol, table, figure {
margin-top: 0;
margin-bottom: 1em;
}
img, svg, video {
max-width: 100%;
height: auto;
}
table {
border-collapse: collapse;
max-width: 100%;
overflow-wrap: anywhere;
}
td, th {
border: 1px solid color-mix(in srgb, var(--reader-fg) 24%, transparent);
padding: 0.35em 0.5em;
vertical-align: top;
}
.reader-highlight {
background: var(--reader-highlight);
color: inherit;
border-radius: 2px;
}
a { color: inherit; text-decoration-thickness: 0.08em; }
</style>
</head>
<body data-search="${searchQuery.escapeHtml()}">
$body
</body>
</html>
""".trimIndent()
}
private fun SharedEpubChapter.toHtml(searchQuery: String): String {
htmlContent.takeIf { it.isNotBlank() }?.let { return it }
semanticBlocks.takeIf { it.isNotEmpty() }?.let { blocks ->
return blocks.joinToString("\n") { it.toHtml(searchQuery) }
}
return plainText.textToParagraphHtml(searchQuery)
}
private fun SemanticBlock.toHtml(searchQuery: String): String {
return when (this) {
is SemanticHeader -> "<h${level.coerceIn(1, 6)}>${text.highlightAndEscape(searchQuery)}</h${level.coerceIn(1, 6)}>"
is SemanticParagraph -> "<p>${text.highlightAndEscape(searchQuery)}</p>"
is SemanticListItem -> "<li>${text.highlightAndEscape(searchQuery)}</li>"
is SemanticList -> {
val tag = if (isOrdered) "ol" else "ul"
"<$tag>${items.joinToString("") { it.toHtml(searchQuery) }}</$tag>"
}
is SemanticImage -> "<figure><img src=\"${path.escapeHtml()}\" alt=\"${altText.orEmpty().escapeHtml()}\"></figure>"
is SemanticMath -> svgContent ?: "<pre>${altText.orEmpty().highlightAndEscape(searchQuery)}</pre>"
is SemanticSpacer -> if (isExplicitLineBreak) "<br>" else "<div style=\"height:1em\"></div>"
is SemanticTable -> rows.joinToString("", "<table><tbody>", "</tbody></table>") { row ->
row.joinToString("", "<tr>", "</tr>") { cell ->
val tag = if (cell.isHeader) "th" else "td"
"<$tag colspan=\"${cell.colspan.coerceAtLeast(1)}\">${cell.content.joinToString("") { it.toHtml(searchQuery) }}</$tag>"
}
}
is SemanticFlexContainer -> children.joinToString("", "<div>", "</div>") { it.toHtml(searchQuery) }
is SemanticWrappingBlock -> floatedImage.toHtml(searchQuery) + paragraphsToWrap.joinToString("") { it.toHtml(searchQuery) }
is SemanticTextBlock -> "<p>${text.highlightAndEscape(searchQuery)}</p>"
}
}
private fun String.textToParagraphHtml(searchQuery: String): String {
return split(Regex("\\n\\s*\\n"))
.filter { it.isNotBlank() }
.joinToString("\n") { "<p>${it.trim().highlightAndEscape(searchQuery)}</p>" }
.ifBlank { "<p></p>" }
}
private fun String.highlightAndEscape(searchQuery: String): String {
val escaped = escapeHtml()
val query = searchQuery.trim()
if (query.length < 2) return escaped
return escaped.replace(Regex(Regex.escape(query.escapeHtml()), RegexOption.IGNORE_CASE)) {
"<span class=\"reader-highlight\">${it.value}</span>"
}
}
private fun String.escapeHtml(): String {
return replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;")
}
}

View file

@ -0,0 +1,102 @@
package com.aryan.reader.shared.reader
import com.aryan.reader.paginatedreader.SemanticBlock
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()
)
data class SharedEpubChapter(
val id: String,
val title: String,
val plainText: String,
val semanticBlocks: List<SemanticBlock> = emptyList(),
val htmlContent: String = "",
val baseHref: String? = null
)
data class ReaderLocator(
val chapterIndex: Int = 0,
val charOffset: Int = 0
)
enum class ReaderReadingMode {
PAGINATED,
VERTICAL
}
enum class SharedReaderTextAlign {
START,
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.PAGINATED,
val textAlign: SharedReaderTextAlign = SharedReaderTextAlign.START,
val pageWidth: Int = 760,
val fontFamily: String = "Default"
)
data class ReaderPage(
val pageIndex: Int,
val chapterIndex: Int,
val chapterTitle: String,
val text: String,
val startOffset: Int,
val endOffset: Int
)
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()) 0f else ((currentPageIndex + 1).toFloat() / pages.size) * 100f
val canGoPrevious: Boolean get() = currentPageIndex > 0
val canGoNext: Boolean get() = currentPageIndex < pages.lastIndex
}
object SampleReaderBooks {
fun desktopWelcomeBook(): SharedEpubBook {
return SharedEpubBook(
id = "desktop_welcome",
fileName = "Desktop Welcome.epub",
title = "Episteme Desktop Reader",
author = "Episteme",
chapters = listOf(
SharedEpubChapter(
id = "intro",
title = "A Careful First Page",
plainText = """
This is the first desktop paginated reader milestone.
It intentionally starts with the quiet parts: page state, chapter navigation, font sizing, margins, light and dark reading surfaces, progress, and a JVM EPUB loader. The Android reader remains where it is, which keeps the mobile app protected while Windows grows its own platform layer.
The next pieces can be added one by one: persisted locations, bookmarks, highlights, table of contents polish, keyboard shortcuts, and eventually the richer pagination engine from Android once its platform-specific parts are behind interfaces.
""".trimIndent()
),
SharedEpubChapter(
id = "scope",
title = "What Works Here",
plainText = """
The desktop shell can import EPUB files and extract readable spine text using the JDK zip APIs. It does not try to render complex CSS, images, MathML, or annotations yet.
That limitation is deliberate. A plain paginated reader gives us a working Windows loop without pulling Android WebView, SAF, Room, PDF, or existing reader screens into the first KMP step.
""".trimIndent()
)
)
)
}
}

View file

@ -0,0 +1,109 @@
package com.aryan.reader.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.margin * 2).coerceAtLeast(360)
val usableHeight = (viewportHeight - settings.margin * 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,887 @@
package com.aryan.reader.shared.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
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.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
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.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.layout.widthIn
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.items
import androidx.compose.foundation.rememberScrollState
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.List
import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Book
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.Edit
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Tag
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
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.OutlinedTextField
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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aryan.reader.shared.BookItem
import com.aryan.reader.shared.FileType
import com.aryan.reader.shared.LibraryFilters
import com.aryan.reader.shared.LibraryAction
import com.aryan.reader.shared.ReadStatusFilter
import com.aryan.reader.shared.Shelf
import com.aryan.reader.shared.ShelfType
import com.aryan.reader.shared.SharedReaderScreenState
import com.aryan.reader.shared.SortOrder
import com.aryan.reader.shared.cardAuthor
import com.aryan.reader.shared.cardTitle
import com.aryan.reader.shared.isOpdsStream
import com.aryan.reader.shared.progressPercentValue
import com.aryan.reader.shared.reduce
import com.aryan.reader.shared.toHomeScreenModel
enum class NonReaderLibraryTab {
BOOKS,
SHELVES,
FOLDERS
}
@Composable
fun SharedHomeScreen(
state: SharedReaderScreenState,
onImportBooks: () -> Unit,
onOpenBook: (BookItem) -> Unit,
onToggleSelection: (String) -> Unit,
onClearSelection: () -> Unit,
onRemoveSelected: () -> Unit,
onShowBookInfo: (BookItem) -> Unit = {},
onEditBook: (BookItem) -> Unit = {},
onTagSelectedBooks: () -> Unit = {},
onAddSelectedBooksToShelf: () -> Unit = {},
modifier: Modifier = Modifier
) {
val model = state.toHomeScreenModel()
NonReaderScreenScaffold(
title = "Home",
subtitle = "Recent books and quick access",
modifier = modifier,
trailing = {
Button(onClick = onImportBooks) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Import")
}
}
) {
if (model.isContextualModeActive) {
SelectionToolbar(
count = model.selectedBooks.size,
onClear = onClearSelection,
onRemove = onRemoveSelected,
onTag = onTagSelectedBooks,
onAddToShelf = onAddSelectedBooksToShelf
)
}
if (model.isEmpty) {
SharedEmptyState(
icon = { Icon(Icons.AutoMirrored.Filled.LibraryBooks, contentDescription = null, modifier = Modifier.size(56.dp)) },
title = "No recent files",
body = if (model.isLibraryEmpty) "Import a few books to populate your library." else "Open books from the library and they will appear here.",
actionLabel = "Import books",
onAction = onImportBooks,
modifier = Modifier.weight(1f)
)
} else {
BookGrid(
books = model.recentBooks,
selectedBookIds = state.selectedBookIds,
onOpenBook = onOpenBook,
onToggleSelection = onToggleSelection,
onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook,
modifier = Modifier.weight(1f)
)
}
}
}
@Composable
fun SharedLibraryScreen(
state: SharedReaderScreenState,
selectedTab: NonReaderLibraryTab,
onTabChange: (NonReaderLibraryTab) -> Unit,
onStateChange: (SharedReaderScreenState) -> Unit,
onImportBooks: () -> Unit,
onOpenBook: (BookItem) -> Unit,
onToggleSelection: (String) -> Unit,
onClearSelection: () -> Unit,
onRemoveSelected: () -> Unit,
onShowBookInfo: (BookItem) -> Unit = {},
onEditBook: (BookItem) -> Unit = {},
onCreateShelf: () -> Unit = {},
onRenameShelf: (Shelf) -> Unit = {},
onDeleteShelf: (Shelf) -> Unit = {},
onTagSelectedBooks: () -> Unit = {},
onAddSelectedBooksToShelf: () -> Unit = {},
modifier: Modifier = Modifier
) {
val books = state.libraryBooks
val shelves = state.shelves
val folderShelves = remember(shelves) { shelves.filter { it.type == ShelfType.FOLDER } }
NonReaderScreenScaffold(
title = "Library",
subtitle = "Search, sort, filter, and organize local metadata",
modifier = modifier,
trailing = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
SortMenu(sortOrder = state.sortOrder, onSortOrderChange = { onStateChange(state.reduce(LibraryAction.SortChanged(it))) })
Button(onClick = onCreateShelf) {
Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Shelf")
}
Button(onClick = onImportBooks) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Import")
}
}
}
) {
if (state.selectedBookIds.isNotEmpty()) {
SelectionToolbar(
count = state.selectedBookIds.size,
onClear = onClearSelection,
onRemove = onRemoveSelected,
onTag = onTagSelectedBooks,
onAddToShelf = onAddSelectedBooksToShelf
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
NonReaderLibraryTab.entries.forEach { tab ->
FilterChip(
selected = selectedTab == tab,
onClick = { onTabChange(tab) },
leadingIcon = {
Icon(
imageVector = when (tab) {
NonReaderLibraryTab.BOOKS -> Icons.Default.Book
NonReaderLibraryTab.SHELVES -> Icons.AutoMirrored.Filled.LibraryBooks
NonReaderLibraryTab.FOLDERS -> Icons.Default.Folder
},
contentDescription = null,
modifier = Modifier.size(18.dp)
)
},
label = { Text(tab.label) }
)
}
}
when (selectedTab) {
NonReaderLibraryTab.BOOKS -> {
LibrarySearchAndFilters(
state = state,
onStateChange = onStateChange
)
if (books.isEmpty()) {
SharedEmptyState(
icon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(56.dp)) },
title = if (state.rawLibraryBooks.isEmpty()) "Your library is empty" else "No books match",
body = if (state.rawLibraryBooks.isEmpty()) "Import books to begin building your desktop library." else "Adjust search, sort, or filters to see more books.",
actionLabel = if (state.rawLibraryBooks.isEmpty()) "Import books" else "Clear filters",
onAction = {
if (state.rawLibraryBooks.isEmpty()) {
onImportBooks()
} else {
onStateChange(state.reduce(LibraryAction.SearchChanged("")).reduce(LibraryAction.FiltersChanged(LibraryFilters())))
}
},
modifier = Modifier.weight(1f)
)
} else {
BookGrid(
books = books,
selectedBookIds = state.selectedBookIds,
onOpenBook = onOpenBook,
onToggleSelection = onToggleSelection,
onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook,
modifier = Modifier.weight(1f)
)
}
}
NonReaderLibraryTab.SHELVES -> ShelfCollection(
shelves = shelves,
selectedBookIds = state.selectedBookIds,
onOpenBook = onOpenBook,
onToggleSelection = onToggleSelection,
onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook,
onRenameShelf = onRenameShelf,
onDeleteShelf = onDeleteShelf,
emptyTitle = "No shelves yet",
emptyBody = "Series, tags, and imported metadata will appear here.",
modifier = Modifier.weight(1f)
)
NonReaderLibraryTab.FOLDERS -> ShelfCollection(
shelves = folderShelves,
selectedBookIds = state.selectedBookIds,
onOpenBook = onOpenBook,
onToggleSelection = onToggleSelection,
onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook,
emptyTitle = "No folders yet",
emptyBody = "Imported folder metadata will appear here when available.",
modifier = Modifier.weight(1f)
)
}
}
}
@Composable
fun SharedShelvesScreen(
shelves: List<Shelf>,
selectedBookIds: Set<String>,
onOpenBook: (BookItem) -> Unit,
onToggleSelection: (String) -> Unit,
onShowBookInfo: (BookItem) -> Unit = {},
onEditBook: (BookItem) -> Unit = {},
onCreateShelf: () -> Unit = {},
onRenameShelf: (Shelf) -> Unit = {},
onDeleteShelf: (Shelf) -> Unit = {},
modifier: Modifier = Modifier
) {
NonReaderScreenScaffold(
title = "Shelves",
subtitle = "Series, folders, and tags from library metadata",
modifier = modifier,
trailing = {
Button(onClick = onCreateShelf) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Shelf")
}
}
) {
ShelfCollection(
shelves = shelves,
selectedBookIds = selectedBookIds,
onOpenBook = onOpenBook,
onToggleSelection = onToggleSelection,
onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook,
onRenameShelf = onRenameShelf,
onDeleteShelf = onDeleteShelf,
emptyTitle = "No shelves yet",
emptyBody = "Add metadata or import folders later to populate shelves.",
modifier = Modifier.weight(1f)
)
}
}
@Composable
private fun NonReaderScreenScaffold(
title: String,
subtitle: String,
modifier: Modifier = Modifier,
trailing: @Composable () -> Unit = {},
content: @Composable ColumnScope.() -> Unit
) {
Column(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
trailing()
}
content()
}
}
@Composable
private fun SelectionToolbar(
count: Int,
onClear: () -> Unit,
onRemove: () -> Unit,
onTag: () -> Unit = {},
onAddToShelf: () -> Unit = {}
) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("$count selected", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.weight(1f))
TextButton(onClick = onTag) {
Icon(Icons.Default.Tag, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("Tag")
}
TextButton(onClick = onAddToShelf) {
Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("Shelf")
}
TextButton(onClick = onClear) {
Text("Clear")
}
TextButton(onClick = onRemove) {
Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("Remove")
}
}
}
}
@Composable
private fun LibrarySearchAndFilters(
state: SharedReaderScreenState,
onStateChange: (SharedReaderScreenState) -> Unit
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(
value = state.searchQuery,
onValueChange = { onStateChange(state.reduce(LibraryAction.SearchChanged(it))) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
label = { Text("Search books, authors, or tags") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
AssistChip(
onClick = {},
label = { Text("Filters") },
leadingIcon = { Icon(Icons.Default.FilterList, contentDescription = null, modifier = Modifier.size(18.dp)) }
)
listOf(FileType.PDF, FileType.EPUB, FileType.MOBI, FileType.DOCX, FileType.TXT).forEach { type ->
FilterChip(
selected = type in state.libraryFilters.fileTypes,
onClick = {
val updated = if (type in state.libraryFilters.fileTypes) state.libraryFilters.fileTypes - type else state.libraryFilters.fileTypes + type
onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(fileTypes = updated))))
},
label = { Text(type.name) }
)
}
ReadStatusFilter.entries.filterNot { it == ReadStatusFilter.ALL }.forEach { status ->
FilterChip(
selected = state.libraryFilters.readStatus == status,
onClick = {
onStateChange(
state.reduce(
LibraryAction.FiltersChanged(
state.libraryFilters.copy(
readStatus = if (state.libraryFilters.readStatus == status) ReadStatusFilter.ALL else status
)
)
)
)
},
label = { Text(status.label) }
)
}
state.allTags.forEach { tag ->
FilterChip(
selected = tag.id in state.libraryFilters.tagIds,
onClick = {
val updated = if (tag.id in state.libraryFilters.tagIds) state.libraryFilters.tagIds - tag.id else state.libraryFilters.tagIds + tag.id
onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(tagIds = updated))))
},
leadingIcon = { Icon(Icons.Default.Tag, contentDescription = null, modifier = Modifier.size(16.dp)) },
label = { Text(tag.name) }
)
}
if (state.libraryFilters.isActive || state.searchQuery.isNotBlank()) {
TextButton(onClick = { onStateChange(state.reduce(LibraryAction.SearchChanged("")).reduce(LibraryAction.FiltersChanged(LibraryFilters()))) }) {
Text("Clear")
}
}
}
}
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun BookGrid(
books: List<BookItem>,
selectedBookIds: Set<String>,
onOpenBook: (BookItem) -> Unit,
onToggleSelection: (String) -> Unit,
onShowBookInfo: (BookItem) -> Unit,
onEditBook: (BookItem) -> Unit,
modifier: Modifier = Modifier
) {
LazyVerticalGrid(
columns = GridCells.Adaptive(340.dp),
modifier = modifier.fillMaxWidth(),
contentPadding = PaddingValues(bottom = 24.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(books, key = { it.id }) { book ->
BookCard(
book = book,
selected = book.id in selectedBookIds,
onOpen = { onOpenBook(book) },
onToggleSelection = { onToggleSelection(book.id) },
onShowInfo = { onShowBookInfo(book) },
onEdit = { onEditBook(book) }
)
}
}
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun BookCard(
book: BookItem,
selected: Boolean,
onOpen: () -> Unit,
onToggleSelection: () -> Unit,
onShowInfo: () -> Unit,
onEdit: () -> Unit
) {
Card(
colors = CardDefaults.cardColors(
containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface
),
border = if (selected) BorderStroke(1.dp, MaterialTheme.colorScheme.primary) else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth().heightIn(min = 156.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(onClick = onOpen, onLongClick = onToggleSelection)
.padding(14.dp),
verticalAlignment = Alignment.Top
) {
BookCover(book = book, selected = selected)
Spacer(Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.Top) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = book.cardTitle(),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = book.cardAuthor(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Row {
IconButton(onClick = onShowInfo, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.Info, contentDescription = "Info")
}
IconButton(onClick = onEdit, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.Edit, contentDescription = "Edit")
}
IconButton(onClick = onToggleSelection, modifier = Modifier.size(36.dp)) {
Icon(
imageVector = if (selected) Icons.Default.Check else Icons.AutoMirrored.Filled.List,
contentDescription = if (selected) "Clear selection" else "Select"
)
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
TypeBadge(book.type)
if (book.sourceFolder != null) {
StatusBadge(Icons.Default.Folder, "Folder")
}
if (book.isOpdsStream()) {
StatusBadge(Icons.Default.Cloud, "Stream")
}
}
ProgressSection(book.progressPercentage)
if (book.tags.isNotEmpty()) {
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
items(book.tags, key = { it.id }) { tag ->
TagChip(tag.name, tag.color)
}
}
}
}
}
}
}
@Composable
private fun BookCover(book: BookItem, selected: Boolean) {
val color = fileTypeColor(book.type)
Surface(
modifier = Modifier.size(width = 64.dp, height = 94.dp),
color = color,
contentColor = Color.White,
shape = RoundedCornerShape(7.dp),
tonalElevation = 2.dp
) {
Box(contentAlignment = Alignment.Center) {
Icon(Icons.Default.Book, contentDescription = null, modifier = Modifier.size(30.dp))
if (selected) {
Surface(
modifier = Modifier.align(Alignment.TopEnd).padding(6.dp),
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
) {
Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.padding(3.dp).size(12.dp))
}
}
Text(
text = book.type.name,
style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 1.sp),
fontWeight = FontWeight.Bold,
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 8.dp)
)
}
}
}
@Composable
private fun TypeBadge(type: FileType) {
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
) {
Text(
type.name,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp)
)
}
}
@Composable
private fun StatusBadge(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String) {
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
) {
Row(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(icon, contentDescription = null, modifier = Modifier.size(13.dp))
Spacer(Modifier.width(4.dp))
Text(label, style = MaterialTheme.typography.labelSmall)
}
}
}
@Composable
private fun TagChip(name: String, color: Int?) {
val tagColor = Color(color ?: 0xFF64B5F6.toInt())
Surface(
shape = RoundedCornerShape(50),
color = tagColor.copy(alpha = 0.14f),
contentColor = tagColor
) {
Row(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Tag, contentDescription = null, modifier = Modifier.size(12.dp))
Spacer(Modifier.width(4.dp))
Text(name, style = MaterialTheme.typography.labelSmall, maxLines = 1)
}
}
}
@Composable
private fun ProgressSection(progressPercentage: Float?) {
val percent = progressPercentValue(progressPercentage)
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Progress", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.weight(1f))
Text("$percent%", style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold)
}
Spacer(Modifier.height(5.dp))
LinearProgressIndicator(
progress = { percent / 100f },
modifier = Modifier.fillMaxWidth().height(5.dp).clip(RoundedCornerShape(50)),
trackColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f)
)
}
}
@Composable
private fun ShelfCollection(
shelves: List<Shelf>,
selectedBookIds: Set<String>,
onOpenBook: (BookItem) -> Unit,
onToggleSelection: (String) -> Unit,
onShowBookInfo: (BookItem) -> Unit,
onEditBook: (BookItem) -> Unit,
onRenameShelf: (Shelf) -> Unit = {},
onDeleteShelf: (Shelf) -> Unit = {},
emptyTitle: String,
emptyBody: String,
modifier: Modifier = Modifier
) {
if (shelves.isEmpty()) {
SharedEmptyState(
icon = { Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(56.dp)) },
title = emptyTitle,
body = emptyBody,
modifier = modifier
)
return
}
LazyColumn(
modifier = modifier.fillMaxWidth(),
contentPadding = PaddingValues(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(18.dp)
) {
items(shelves, key = { it.id }) { shelf ->
ShelfSection(
shelf = shelf,
selectedBookIds = selectedBookIds,
onOpenBook = onOpenBook,
onToggleSelection = onToggleSelection,
onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook,
onRenameShelf = onRenameShelf,
onDeleteShelf = onDeleteShelf
)
}
}
}
@Composable
private fun ShelfSection(
shelf: Shelf,
selectedBookIds: Set<String>,
onOpenBook: (BookItem) -> Unit,
onToggleSelection: (String) -> Unit,
onShowBookInfo: (BookItem) -> Unit,
onEditBook: (BookItem) -> Unit,
onRenameShelf: (Shelf) -> Unit,
onDeleteShelf: (Shelf) -> Unit
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = when (shelf.type) {
ShelfType.FOLDER -> Icons.Default.Folder
ShelfType.TAG -> Icons.Default.Tag
else -> Icons.AutoMirrored.Filled.LibraryBooks
},
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.width(8.dp))
Text(shelf.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.width(8.dp))
AssistChip(onClick = {}, label = { Text("${shelf.bookCount}") })
if (shelf.type == ShelfType.MANUAL && shelf.id != "unshelved") {
Spacer(Modifier.weight(1f))
IconButton(onClick = { onRenameShelf(shelf) }, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Edit, contentDescription = "Rename shelf", modifier = Modifier.size(18.dp))
}
IconButton(onClick = { onDeleteShelf(shelf) }, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Delete, contentDescription = "Delete shelf", modifier = Modifier.size(18.dp))
}
}
}
LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
items(shelf.books, key = { it.id }) { book ->
Box(modifier = Modifier.width(360.dp)) {
BookCard(
book = book,
selected = book.id in selectedBookIds,
onOpen = { onOpenBook(book) },
onToggleSelection = { onToggleSelection(book.id) },
onShowInfo = { onShowBookInfo(book) },
onEdit = { onEditBook(book) }
)
}
}
}
}
}
@Composable
private fun SortMenu(
sortOrder: SortOrder,
onSortOrderChange: (SortOrder) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
Box {
Button(onClick = { expanded = true }) {
Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(sortOrder.label)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
SortOrder.entries.forEach { order ->
DropdownMenuItem(
text = { Text(order.label) },
onClick = {
expanded = false
onSortOrderChange(order)
}
)
}
}
}
}
@Composable
private fun SharedEmptyState(
icon: @Composable () -> Unit,
title: String,
body: String,
modifier: Modifier = Modifier,
actionLabel: String? = null,
onAction: (() -> Unit)? = null
) {
Surface(
modifier = modifier.fillMaxWidth().fillMaxHeight(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
) {
Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
Surface(shape = RoundedCornerShape(18.dp), color = MaterialTheme.colorScheme.surfaceVariant) {
Box(Modifier.padding(18.dp), contentAlignment = Alignment.Center) {
icon()
}
}
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center)
Text(
body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.widthIn(max = 420.dp)
)
if (actionLabel != null && onAction != null) {
Spacer(Modifier.height(6.dp))
Button(onClick = onAction) {
Text(actionLabel)
}
}
}
}
}
}
private val NonReaderLibraryTab.label: String
get() = when (this) {
NonReaderLibraryTab.BOOKS -> "Books"
NonReaderLibraryTab.SHELVES -> "Shelves"
NonReaderLibraryTab.FOLDERS -> "Folders"
}
private val SortOrder.label: String
get() = when (this) {
SortOrder.RECENT -> "Recent"
SortOrder.TITLE_ASC -> "Title A-Z"
SortOrder.AUTHOR_ASC -> "Author A-Z"
SortOrder.PERCENT_ASC -> "Progress low"
SortOrder.PERCENT_DESC -> "Progress high"
SortOrder.SIZE_ASC -> "Size small"
SortOrder.SIZE_DESC -> "Size large"
}
private val ReadStatusFilter.label: String
get() = when (this) {
ReadStatusFilter.ALL -> "All"
ReadStatusFilter.UNREAD -> "Unread"
ReadStatusFilter.IN_PROGRESS -> "In progress"
ReadStatusFilter.COMPLETED -> "Complete"
}
private fun fileTypeColor(type: FileType): Color {
return when (type) {
FileType.PDF -> Color(0xFF9C4146)
FileType.EPUB, FileType.MOBI -> Color(0xFF006C4C)
FileType.DOCX, FileType.ODT, FileType.FODT -> Color(0xFF0F52BA)
FileType.CBZ, FileType.CBR, FileType.CB7 -> Color(0xFF705D49)
else -> Color(0xFF5D6B82)
}
}

View file

@ -0,0 +1,3 @@
package com.aryan.reader.shared
actual fun currentTimestamp(): Long = System.currentTimeMillis()

View file

@ -0,0 +1,664 @@
/*
* 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 com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.sp
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode
import org.jsoup.select.Selector
private val unsupportedPseudoElementRegex = Regex("::?(before|after|first-letter|first-line|marker|selection)", RegexOption.IGNORE_CASE)
interface HtmlResourceResolver {
fun resolvePath(chapterAbsPath: String, extractionBasePath: String, src: String): String?
fun readText(path: String): String?
fun imageDimensions(path: String): Pair<Float?, Float?>?
}
interface HtmlFontFamilyLoader {
fun load(fontFaces: List<FontFaceInfo>, extractionBasePath: String): Map<String, FontFamily>
}
object NoOpHtmlResourceResolver : HtmlResourceResolver {
override fun resolvePath(chapterAbsPath: String, extractionBasePath: String, src: String): String? = null
override fun readText(path: String): String? = null
override fun imageDimensions(path: String): Pair<Float?, Float?>? = null
}
object NoOpHtmlFontFamilyLoader : HtmlFontFamilyLoader {
override fun load(fontFaces: List<FontFaceInfo>, extractionBasePath: String): Map<String, FontFamily> = emptyMap()
}
private object HtmlParserLog {
fun d(@Suppress("UNUSED_PARAMETER") message: String) = Unit
fun w(@Suppress("UNUSED_PARAMETER") throwable: Throwable, @Suppress("UNUSED_PARAMETER") message: String) = Unit
fun e(@Suppress("UNUSED_PARAMETER") throwable: Throwable, @Suppress("UNUSED_PARAMETER") message: String) = Unit
}
private fun Element.getCfiPath(): String {
val path = mutableListOf<Int>()
var currentNode: Node? = this
while (currentNode != null && (currentNode !is Element || currentNode.tagName() != "body")) {
val parent = currentNode.parent() ?: break
val children = parent.childNodes().filter { node ->
node is Element || (node is TextNode && node.text().trim().isNotEmpty())
}
val nodeIndex = children.indexOf(currentNode)
if (nodeIndex == -1) {
currentNode = parent
continue
}
val cfiIndex = (nodeIndex * 2) + 2
path.add(0, cfiIndex)
currentNode = parent
}
path.add(0, 4)
return "/" + path.joinToString("/")
}
private fun String.capitalizeWords(): String =
split(' ').joinToString(" ") { word ->
if (word.isNotEmpty()) word.replaceFirstChar { it.titlecase() } else ""
}
/**
* The public entry point for converting HTML to a list of [SemanticBlock]s.
* This function sets up a parsing context and delegates the work to a [SemanticHtmlParser] instance.
*/
fun htmlToSemanticBlocks(
html: String,
cssRules: OptimizedCssRules,
textStyle: TextStyle,
chapterAbsPath: String,
extractionBasePath: String,
density: Density,
fontFamilyMap: Map<String, FontFamily>,
constraints: Constraints,
imageDimensionsCache: Map<String, Pair<Float, Float>> = emptyMap(),
mathSvgCache: Map<String, String> = emptyMap(),
resourceResolver: HtmlResourceResolver = NoOpHtmlResourceResolver,
fontFamilyLoader: HtmlFontFamilyLoader = NoOpHtmlFontFamilyLoader
): List<SemanticBlock> {
return SemanticHtmlParser(
cssRules,
textStyle,
chapterAbsPath,
extractionBasePath,
density,
fontFamilyMap,
constraints,
imageDimensionsCache,
mathSvgCache,
resourceResolver,
fontFamilyLoader
).parse(html)
}
/**
* A stateful parser that holds the context for a single HTML-to-SemanticBlock conversion.
*/
private class SemanticHtmlParser(
cssRules: OptimizedCssRules,
private val textStyle: TextStyle,
private val chapterAbsPath: String,
private val extractionBasePath: String,
private val density: Density,
fontFamilyMap: Map<String, FontFamily>,
private val constraints: Constraints,
private val imageDimensionsCache: Map<String, Pair<Float, Float>>,
private val mathSvgCache: Map<String, String>,
private val resourceResolver: HtmlResourceResolver,
private val fontFamilyLoader: HtmlFontFamilyLoader
) {
private val styleCache = mutableMapOf<String, CssStyle>()
private var combinedRules: OptimizedCssRules = cssRules
private val currentFontFamilyMap: MutableMap<String, FontFamily> = fontFamilyMap.toMutableMap()
private var nextBlockIndex = 0
fun parse(html: String): List<SemanticBlock> {
val document = Jsoup.parse(html, chapterAbsPath)
val inlineCssContent = document.head().select("style").joinToString(separator = "\n") { it.data() }
if (inlineCssContent.isNotBlank()) {
HtmlParserLog.d("Found inline <style> content in $chapterAbsPath. Parsing...")
val inlineParseResult = CssParser.parse(
cssContent = inlineCssContent,
cssPath = chapterAbsPath,
baseFontSizeSp = textStyle.fontSize.value,
density = density.density,
constraints = constraints,
isDarkTheme = false
)
if (inlineParseResult.fontFaces.isNotEmpty()) {
val newFonts = fontFamilyLoader.load(inlineParseResult.fontFaces, extractionBasePath)
if (newFonts.isNotEmpty()) {
currentFontFamilyMap.putAll(newFonts)
}
}
combinedRules = combinedRules.merge(inlineParseResult.rules)
}
val body = document.body()
return parseContainer(body, getElementStyle(body))
}
private fun parseNodeToSemanticBlocks(
element: Element,
inheritedStyle: CssStyle
): List<SemanticBlock> {
val elementOwnStyle = getElementStyle(element)
val finalBlockStyle = elementOwnStyle.blockStyle.copy(
listStyleType = elementOwnStyle.blockStyle.listStyleType ?: inheritedStyle.blockStyle.listStyleType,
listStyleImage = elementOwnStyle.blockStyle.listStyleImage ?: inheritedStyle.blockStyle.listStyleImage
)
val finalStyle = elementOwnStyle.copy(
spanStyle = inheritedStyle.spanStyle.merge(elementOwnStyle.spanStyle),
paragraphStyle = inheritedStyle.paragraphStyle.merge(elementOwnStyle.paragraphStyle),
blockStyle = finalBlockStyle,
fontFamilies = elementOwnStyle.fontFamilies.ifEmpty { inheritedStyle.fontFamilies },
fontSize = if (elementOwnStyle.fontSize.isSpecified) elementOwnStyle.fontSize else inheritedStyle.fontSize,
textTransform = elementOwnStyle.textTransform ?: inheritedStyle.textTransform,
hyphens = elementOwnStyle.hyphens ?: inheritedStyle.hyphens,
fontVariantNumeric = elementOwnStyle.fontVariantNumeric ?: inheritedStyle.fontVariantNumeric,
textEmphasis = elementOwnStyle.textEmphasis ?: inheritedStyle.textEmphasis
)
if (finalStyle.display == "none") return emptyList()
return elementToSemanticBlocks(element, finalStyle)
}
private fun getElementDescriptor(element: Element): String {
return buildString {
append(element.tagName())
val id = element.id()
if (id.isNotEmpty()) append('#').append(id)
val classes = element.classNames()
if (classes.isNotEmpty()) append('.').append(classes.sorted().joinToString("."))
}
}
private fun getElementStyle(element: Element): CssStyle {
val cacheKey = getElementDescriptor(element)
val baseStyle = styleCache.getOrPut(cacheKey) {
val potentialRules = mutableListOf<CssRule>()
combinedRules.byTag[element.tagName()]?.let { potentialRules.addAll(it) }
element.id().takeIf { it.isNotEmpty() }?.let { id ->
combinedRules.byId[id]?.let { potentialRules.addAll(it) }
}
element.classNames().forEach { className ->
combinedRules.byClass[className]?.let { potentialRules.addAll(it) }
}
potentialRules.addAll(combinedRules.otherComplex)
val matchingRules = potentialRules.filter { rule ->
if (unsupportedPseudoElementRegex.containsMatchIn(rule.selector.selector)) return@filter false
try {
element.`is`(rule.selector.selector)
} catch (e: Selector.SelectorParseException) {
HtmlParserLog.w(e, "Jsoup failed to parse selector '${rule.selector.selector}'.")
false
}
}
matchingRules.sortedBy { it.selector.specificity }.fold(CssStyle()) { acc, rule ->
acc.merge(rule.style)
}
}
var elementStyle = baseStyle
val inlineStyleAttribute = element.attr("style")
if (inlineStyleAttribute.isNotBlank()) {
val inlineStyle = CssParser.parseProperties(inlineStyleAttribute, textStyle.fontSize.value, density.density, constraints, onlyImportant = false, isDarkTheme = false)
elementStyle = elementStyle.merge(inlineStyle)
}
element.attr("align").takeIf { it.isNotBlank() }?.let { align ->
val textAlign = when (align.lowercase()) {
"center" -> TextAlign.Center; "right" -> TextAlign.End
"justify" -> TextAlign.Justify; "left" -> TextAlign.Start
else -> null
}
if (textAlign != null) {
elementStyle = elementStyle.merge(CssStyle(paragraphStyle = ParagraphStyle(textAlign = textAlign)))
}
}
return elementStyle
}
private fun elementToSemanticBlocks(
element: Element,
elementStyle: CssStyle
): List<SemanticBlock> {
val elementId = element.id().ifBlank { null }
val cfi = element.getCfiPath()
if (element.tagName().equals("br", ignoreCase = true)) {
return listOf(SemanticSpacer(style = elementStyle, elementId = elementId, cfi = cfi, isExplicitLineBreak = true, blockIndex = nextBlockIndex++))
}
if (elementStyle.blockStyle.display == "flex") {
val children = element.children().flatMap { child ->
parseNodeToSemanticBlocks(child, elementStyle)
}
return listOf(SemanticFlexContainer(children, elementStyle, elementId, cfi, blockIndex = nextBlockIndex++))
}
val result = when (val tagName = element.tagName().lowercase()) {
"div", "header", "section", "article", "aside", "main", "footer", "nav", "figure" -> {
val hasBoxStyles = elementStyle.blockStyle.backgroundColor.isSpecified ||
elementStyle.blockStyle.borderTop != null ||
elementStyle.blockStyle.borderRight != null ||
elementStyle.blockStyle.borderBottom != null ||
elementStyle.blockStyle.borderLeft != null ||
elementStyle.blockStyle.padding != BoxBorders() ||
elementStyle.blockStyle.borderTopLeftRadius > 0.dp ||
elementStyle.blockStyle.borderTopRightRadius > 0.dp ||
elementStyle.blockStyle.borderBottomRightRadius > 0.dp ||
elementStyle.blockStyle.borderBottomLeftRadius > 0.dp
if (hasBoxStyles) {
val childStyle = elementStyle.copy(
blockStyle = elementStyle.blockStyle.copy(
backgroundColor = Color.Unspecified,
borderTop = null, borderRight = null, borderBottom = null, borderLeft = null,
padding = BoxBorders(),
margin = BoxBorders()
)
)
val children = parseContainer(element, childStyle)
listOf(SemanticFlexContainer(children, elementStyle, elementId, cfi, blockIndex = nextBlockIndex++))
} else {
parseContainer(element, elementStyle)
}
}
"svg" -> parseSvgElementToSemantic(element, elementStyle)?.let { listOf(it) } ?: emptyList()
"table" -> parseTableElementToSemantic(element, elementStyle)?.let { listOf(it) } ?: emptyList()
"math-placeholder" -> parseMathPlaceholderToSemantic(element, elementStyle)
"img" -> parseImageElementToSemantic(element, elementStyle)?.let { listOf(it) } ?: emptyList()
"h1", "h2", "h3", "h4", "h5", "h6" -> {
val hasNonTextChildren = element.select("img, svg, math-placeholder, table, hr, div, p, h1, h2, h3, h4, h5, h6, ul, ol, li, blockquote, figure, article, aside, header, footer, nav, section, main").isNotEmpty()
if (hasNonTextChildren) {
val level = tagName.substring(1).toIntOrNull() ?: 1
val fontSizeMultiplier = when (level) {
1 -> 1.5f; 2 -> 1.4f; 3 -> 1.3f; 4 -> 1.2f; 5 -> 1.1f; else -> 1.0f
}
val headerStyle = elementStyle.copy(
spanStyle = elementStyle.spanStyle.copy(
fontWeight = FontWeight.Bold,
fontSize = (textStyle.fontSize.value * fontSizeMultiplier).sp
)
)
val hasBoxStyles = headerStyle.blockStyle.backgroundColor.isSpecified ||
headerStyle.blockStyle.borderTop != null ||
headerStyle.blockStyle.borderRight != null ||
headerStyle.blockStyle.borderBottom != null ||
headerStyle.blockStyle.borderLeft != null ||
headerStyle.blockStyle.padding != BoxBorders() ||
headerStyle.blockStyle.borderTopLeftRadius > 0.dp ||
headerStyle.blockStyle.borderTopRightRadius > 0.dp ||
headerStyle.blockStyle.borderBottomRightRadius > 0.dp ||
headerStyle.blockStyle.borderBottomLeftRadius > 0.dp
if (hasBoxStyles) {
val childStyle = headerStyle.copy(
blockStyle = headerStyle.blockStyle.copy(
backgroundColor = Color.Unspecified,
borderTop = null, borderRight = null, borderBottom = null, borderLeft = null,
padding = BoxBorders(),
margin = BoxBorders()
)
)
val children = parseContainer(element, childStyle)
listOf(SemanticFlexContainer(children, headerStyle, elementId, cfi, blockIndex = nextBlockIndex++))
} else {
parseContainer(element, headerStyle)
}
} else {
val (text, spans) = buildSemanticTextAndSpans(element, elementStyle)
if (text.isNotBlank()) {
val level = tagName.substring(1).toIntOrNull() ?: 1
listOf(SemanticHeader(level, text, spans, elementStyle, elementId, cfi, blockIndex = nextBlockIndex++))
} else emptyList()
}
}
"hr" -> listOf(SemanticSpacer(style = elementStyle, elementId = elementId, cfi = cfi, blockIndex = nextBlockIndex++))
"ul", "ol" -> parseListElementToSemantic(element, elementStyle)
else -> {
val hasBlockDescendant = !element.isBlock && element.select("img, svg, math-placeholder, hr, table, div, p, h1, h2, h3, h4, h5, h6, ul, ol, li, blockquote, figure, article, aside, header, footer, nav, section, main").isNotEmpty()
if (element.isBlock || hasBlockDescendant) {
parseContainer(element, elementStyle)
} else {
val (text, spans) = buildSemanticTextAndSpans(element, elementStyle)
if (text.isNotBlank()) {
listOf(SemanticParagraph(text, spans, elementStyle, elementId, cfi, blockIndex = nextBlockIndex++))
} else emptyList()
}
}
}
return if (elementId != null && result.isNotEmpty()) {
val first = result.first()
if (first.elementId == null) {
listOf(first.withElementId(elementId)) + result.drop(1)
} else result
} else result
}
private fun parseContainer(element: Element, style: CssStyle): List<SemanticBlock> {
val children = mutableListOf<SemanticBlock>()
val textNodesBuffer = mutableListOf<Node>()
fun flushTextBuffer() {
if (textNodesBuffer.isEmpty()) return
val (text, spans) = buildSemanticTextAndSpansFromNodes(textNodesBuffer, style)
if (text.isNotBlank()) {
val finalSpans = spans.toMutableList()
if (element.tagName().lowercase() == "a") {
val href = element.attr("href").ifBlank { null }
if (href != null) {
finalSpans.add(SemanticSpan(
start = 0,
end = text.length,
style = style,
linkHref = href,
tag = "a",
elementId = element.id().ifBlank { null }
))
}
}
children.add(SemanticParagraph(text, finalSpans, style, element.id().ifBlank { null }, element.getCfiPath(), blockIndex = nextBlockIndex++))
}
textNodesBuffer.clear()
}
element.childNodes().forEach { node ->
if (node is Element) {
val tagName = node.tagName().lowercase()
val isEffectivelyBlock = node.isBlock || tagName in listOf("img", "svg", "math-placeholder", "hr") ||
(!node.isBlock && node.select("img, svg, math-placeholder, hr, table, div, p, h1, h2, h3, h4, h5, h6, ul, ol, li, blockquote, figure, article, aside, header, footer, nav, section, main").isNotEmpty())
if (isEffectivelyBlock) {
flushTextBuffer()
children.addAll(parseNodeToSemanticBlocks(node, style))
} else {
textNodesBuffer.add(node)
}
} else {
textNodesBuffer.add(node)
}
}
flushTextBuffer()
return children
}
private fun buildSemanticTextAndSpans(
rootElement: Element,
rootStyle: CssStyle
): Pair<String, List<SemanticSpan>> {
return buildSemanticTextAndSpansFromNodes(rootElement.childNodes(), rootStyle)
}
private fun buildSemanticTextAndSpansFromNodes(
nodes: List<Node>,
rootStyle: CssStyle
): Pair<String, List<SemanticSpan>> {
val textBuilder = StringBuilder()
val spans = mutableListOf<SemanticSpan>()
fun processNode(node: Node, inheritedStyle: CssStyle) {
when (node) {
is TextNode -> {
var text = node.wholeText.replace('\n', ' ')
when (inheritedStyle.textTransform) {
"uppercase" -> text = text.uppercase()
"lowercase" -> text = text.lowercase()
"capitalize" -> text = text.capitalizeWords()
}
textBuilder.append(text)
}
is Element -> {
if (node.tagName().lowercase() == "br") {
textBuilder.append('\n'); return
}
val currentElementStyle = getElementStyle(node)
val newStyle = inheritedStyle.merge(currentElementStyle)
val startIndex = textBuilder.length
node.childNodes().forEach { processNode(it, newStyle) }
val endIndex = textBuilder.length
val elementId = node.id().ifBlank { null }
val isAnchor = node.tagName().lowercase() == "a" || elementId != null
// Capture span if it has content OR if it has an ID (anchor)
if (startIndex < endIndex || elementId != null) {
val href = if (node.tagName().lowercase() == "a") node.attr("href").ifBlank { null } else null
spans.add(SemanticSpan(
start = startIndex,
end = endIndex,
style = newStyle,
linkHref = href,
tag = node.tagName().lowercase(),
elementId = elementId // Pass the ID here
))
}
}
}
}
nodes.forEach { processNode(it, rootStyle) }
var processedText = textBuilder.toString()
if (processedText.isNotEmpty() && processedText.last().isWhitespace()) {
// 1. Find the index where trailing whitespace begins
var newLength = processedText.length
while (newLength > 0 && processedText[newLength - 1].isWhitespace()) {
newLength--
}
// 2. Cut the text
processedText = processedText.substring(0, newLength)
// 3. Filter or Cap spans so they don't point to indices that no longer exist
val adjustedSpans = spans.mapNotNull { span ->
if (span.start >= newLength) {
// Span started in the whitespace area, remove it
null
} else if (span.end > newLength) {
// Span ended in the whitespace area, cap it
span.copy(end = newLength)
} else {
span
}
}
return processedText to adjustedSpans
}
return processedText to spans
}
private fun parseMathPlaceholderToSemantic(element: Element, style: CssStyle): List<SemanticBlock> {
val uniqueId = element.id()
val svgContent = mathSvgCache[uniqueId]
val altText = element.attr("alttext").ifBlank { "Equation" }
var svgWidth: String? = null
var svgHeight: String? = null
var svgViewBox: String? = null
if (svgContent != null) {
val svgDoc = Jsoup.parse(svgContent)
svgDoc.selectFirst("svg")?.let {
svgWidth = it.attr("width")
svgHeight = it.attr("height")
svgViewBox = it.attr("viewBox")
}
}
return listOf(
SemanticMath(
svgContent, altText, svgWidth, svgHeight, svgViewBox,
isFromMathJax = true, style = style,
elementId = element.id().ifBlank { null }, cfi = element.getCfiPath(), blockIndex = nextBlockIndex++
)
)
}
private fun parseSvgElementToSemantic(svgElement: Element, style: CssStyle): SemanticBlock? {
val children = svgElement.children()
val imageElement = children.firstOrNull()?.takeIf { children.size == 1 && it.tagName() == "image" }
if (imageElement != null) {
HtmlParserLog.d("Detected SVG acting as a wrapper for an image. Parsing as SemanticImage.")
val href = imageElement.attr("href").ifBlank { imageElement.attr("xlink:href") }
if (href.isBlank()) return null
val imagePath = resolveImagePath(href) ?: return null
val (width, height) = imageDimensionsCache[imagePath]
?: resourceResolver.imageDimensions(imagePath)
?: Pair(null, null)
return SemanticImage(
path = imagePath,
altText = svgElement.selectFirst("title")?.text() ?: "Cover Image",
intrinsicWidth = width,
intrinsicHeight = height,
style = style,
elementId = svgElement.id().ifBlank { null },
cfi = svgElement.getCfiPath(),
blockIndex = nextBlockIndex++
)
}
HtmlParserLog.d("Parsing genuine SVG content into SemanticMath block.")
val title = svgElement.selectFirst("title")?.text()
val desc = svgElement.selectFirst("desc")?.text()
val altText = title ?: desc ?: "SVG Image"
return SemanticMath(
svgContent = svgElement.outerHtml(),
altText = altText,
style = style,
elementId = svgElement.id().ifBlank { null },
cfi = svgElement.getCfiPath(),
svgWidth = svgElement.attr("width").ifBlank { null },
svgHeight = svgElement.attr("height").ifBlank { null },
svgViewBox = svgElement.attr("viewBox").ifBlank { null },
isFromMathJax = false,
blockIndex = nextBlockIndex++
)
}
private fun parseImageElementToSemantic(element: Element, style: CssStyle): SemanticBlock? {
val src = element.attr("src")
if (src.isBlank()) return null
val imagePath = resolveImagePath(src) ?: return null
if (imagePath.substringAfterLast('.', "").equals("svg", ignoreCase = true)) {
return try {
val svgContent = resourceResolver.readText(imagePath) ?: return null
val svgElement = Jsoup.parseBodyFragment(svgContent).body().children().firstOrNull()
svgElement?.let { parseSvgElementToSemantic(it, style) }
} catch (e: Exception) {
HtmlParserLog.e(e, "Failed to read SVG from <img> tag: $imagePath")
null
}
}
val (width, height) = imageDimensionsCache[imagePath]
?: resourceResolver.imageDimensions(imagePath)
?: Pair(null, null)
return SemanticImage(
path = imagePath,
altText = element.attr("alt"),
intrinsicWidth = width,
intrinsicHeight = height,
style = style,
elementId = element.id().ifBlank { null },
cfi = element.getCfiPath(),
blockIndex = nextBlockIndex++
)
}
private fun resolveImagePath(src: String): String? {
if (src.isBlank()) return null
return resourceResolver.resolvePath(chapterAbsPath, extractionBasePath, src)
}
private fun parseListElementToSemantic(listElement: Element, listStyle: CssStyle): List<SemanticBlock> {
val isOrdered = listElement.tagName().lowercase() == "ol"
val items = listElement.children().mapNotNull { child ->
if (child.tagName().lowercase() != "li") return@mapNotNull null
val itemStyle = listStyle.merge(getElementStyle(child))
val (text, spans) = buildSemanticTextAndSpans(child, itemStyle)
val imageSrc = itemStyle.blockStyle.listStyleImage?.let { resolveImagePath(it) }
SemanticListItem(text, spans, itemStyle, child.id().ifBlank { null }, child.getCfiPath(), 0, imageSrc, blockIndex = nextBlockIndex++)
}
return listOf(SemanticList(items, isOrdered, listStyle, listElement.id().ifBlank { null }, listElement.getCfiPath(), blockIndex = nextBlockIndex++))
}
private fun parseTableElementToSemantic(tableElement: Element, tableStyle: CssStyle): SemanticTable? {
val rows = tableElement.select("tr").mapNotNull { rowElement ->
val rowStyle = getElementStyle(rowElement)
if (rowStyle.display == "none") return@mapNotNull null
val cells = rowElement.children().mapNotNull { cellElement ->
val tagName = cellElement.tagName().lowercase()
if (tagName !in listOf("td", "th")) return@mapNotNull null
var cellCssStyle = getElementStyle(cellElement)
if (cellCssStyle.display == "none") return@mapNotNull null
if (!cellCssStyle.blockStyle.backgroundColor.isSpecified) {
if (rowStyle.blockStyle.backgroundColor.isSpecified) {
cellCssStyle = cellCssStyle.copy(
blockStyle = cellCssStyle.blockStyle.copy(
backgroundColor = rowStyle.blockStyle.backgroundColor
)
)
}
}
val cellContent = parseContainer(cellElement, cellCssStyle)
SemanticTableCell(cellContent, tagName == "th", cellElement.attr("colspan").toIntOrNull() ?: 1, cellCssStyle)
}
cells.ifEmpty { null }
}
if (rows.isEmpty()) return null
return SemanticTable(rows, tableStyle, tableElement.id().ifBlank { null }, tableElement.getCfiPath(), blockIndex = nextBlockIndex++)
}
}