v1.0.47 (#279)
* 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:
parent
f42de6b462
commit
d7a9cae9e1
126 changed files with 15287 additions and 3154 deletions
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'")
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
actual fun currentTimestamp(): Long = System.currentTimeMillis()
|
||||
|
|
@ -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++)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue