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,81 @@
|
|||
package com.aryan.reader.paginatedreader
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import java.io.File
|
||||
import java.net.URLDecoder
|
||||
import java.nio.file.Paths
|
||||
|
||||
object AndroidHtmlResourceResolver : HtmlResourceResolver {
|
||||
override fun resolvePath(chapterAbsPath: String, extractionBasePath: String, src: String): String? {
|
||||
if (src.isBlank()) return null
|
||||
val decodedSrc = try {
|
||||
URLDecoder.decode(src, "UTF-8")
|
||||
} catch (_: Exception) {
|
||||
src
|
||||
}
|
||||
val parentPath = File(chapterAbsPath).parent ?: ""
|
||||
val relativePath = Paths.get(parentPath, decodedSrc).normalize().toString()
|
||||
val fromRelativeFile = File(extractionBasePath, relativePath)
|
||||
|
||||
return try {
|
||||
when {
|
||||
fromRelativeFile.exists() -> fromRelativeFile.canonicalFile.absolutePath
|
||||
File(extractionBasePath, decodedSrc).exists() -> File(extractionBasePath, decodedSrc).canonicalFile.absolutePath
|
||||
else -> null
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun readText(path: String): String? {
|
||||
return runCatching { File(path).readText() }.getOrNull()
|
||||
}
|
||||
|
||||
override fun imageDimensions(path: String): Pair<Float?, Float?>? {
|
||||
return runCatching {
|
||||
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(path, options)
|
||||
if (options.outWidth > 0 && options.outHeight > 0) {
|
||||
options.outWidth.toFloat() to options.outHeight.toFloat()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
object AndroidHtmlFontFamilyLoader : HtmlFontFamilyLoader {
|
||||
override fun load(fontFaces: List<FontFaceInfo>, extractionBasePath: String): Map<String, FontFamily> {
|
||||
return loadFontFamilies(fontFaces, extractionBasePath)
|
||||
}
|
||||
}
|
||||
|
||||
fun androidHtmlToSemanticBlocks(
|
||||
html: String,
|
||||
cssRules: OptimizedCssRules,
|
||||
textStyle: androidx.compose.ui.text.TextStyle,
|
||||
chapterAbsPath: String,
|
||||
extractionBasePath: String,
|
||||
density: androidx.compose.ui.unit.Density,
|
||||
fontFamilyMap: Map<String, FontFamily>,
|
||||
constraints: androidx.compose.ui.unit.Constraints,
|
||||
imageDimensionsCache: Map<String, Pair<Float, Float>> = emptyMap(),
|
||||
mathSvgCache: Map<String, String> = emptyMap()
|
||||
): List<SemanticBlock> {
|
||||
return htmlToSemanticBlocks(
|
||||
html = html,
|
||||
cssRules = cssRules,
|
||||
textStyle = textStyle,
|
||||
chapterAbsPath = chapterAbsPath,
|
||||
extractionBasePath = extractionBasePath,
|
||||
density = density,
|
||||
fontFamilyMap = fontFamilyMap,
|
||||
constraints = constraints,
|
||||
imageDimensionsCache = imageDimensionsCache,
|
||||
mathSvgCache = mathSvgCache,
|
||||
resourceResolver = AndroidHtmlResourceResolver,
|
||||
fontFamilyLoader = AndroidHtmlFontFamilyLoader
|
||||
)
|
||||
}
|
||||
|
|
@ -120,7 +120,8 @@ class BookPaginator(
|
|||
private val mathMLRenderer: MathMLRenderer,
|
||||
private val userTextAlign: TextAlign?,
|
||||
private val paragraphGapMultiplier: Float,
|
||||
private val imageSizeMultiplier: Float
|
||||
private val imageSizeMultiplier: Float,
|
||||
private val verticalMarginMultiplier: Float
|
||||
) : IPaginator {
|
||||
override var totalPageCount by mutableIntStateOf(0)
|
||||
private set
|
||||
|
|
@ -185,6 +186,14 @@ class BookPaginator(
|
|||
isLoading = true
|
||||
Timber.d("Initialization started.")
|
||||
|
||||
if (chapters.isEmpty()) {
|
||||
totalPageCount = 0
|
||||
pageCountsAreAccurate = true
|
||||
isLoading = false
|
||||
Timber.w("Paginator initialized with no chapters. Skipping pagination startup.")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// 1. Book processing check (Keep existing logic)
|
||||
val bookRecord = bookCacheDao.getProcessedBook(bookId)
|
||||
if (bookRecord == null || bookRecord.processingVersion < LATEST_PROCESSING_VERSION) {
|
||||
|
|
@ -214,7 +223,7 @@ class BookPaginator(
|
|||
// 5. Prioritize CURRENT chapter only
|
||||
// We no longer blindly queue neighbors immediately to keep startup fast.
|
||||
// We only queue the requested chapter.
|
||||
val startChapter = initialChapterToPaginate.coerceIn(0, chapters.size - 1)
|
||||
val startChapter = initialChapterToPaginate.coerceIn(0, chapters.lastIndex)
|
||||
|
||||
// Trigger actual pagination for the current chapter to replace the estimate with reality
|
||||
triggerPagination(startChapter, PRIORITY_HIGHEST)
|
||||
|
|
@ -277,6 +286,7 @@ class BookPaginator(
|
|||
append("-ta:$userTextAlign")
|
||||
append("-pg:$paragraphGapMultiplier")
|
||||
append("-img:$imageSizeMultiplier")
|
||||
append("-vm:$verticalMarginMultiplier")
|
||||
}
|
||||
val hash = configString.hashCode()
|
||||
return hash
|
||||
|
|
@ -503,7 +513,7 @@ class BookPaginator(
|
|||
parsingCssRules = parsingCssRules.merge(bookCssResult.rules)
|
||||
}
|
||||
|
||||
val semanticBlocks = htmlToSemanticBlocks(
|
||||
val semanticBlocks = androidHtmlToSemanticBlocks(
|
||||
html = processedHtml,
|
||||
cssRules = parsingCssRules,
|
||||
textStyle = textStyle.copy(color = Color.Black),
|
||||
|
|
@ -792,6 +802,10 @@ class BookPaginator(
|
|||
}
|
||||
|
||||
private fun triggerPagination(chapterIndex: Int, priority: Int) {
|
||||
if (chapterIndex !in chapters.indices) {
|
||||
Timber.w("Trigger: Ignoring invalid chapter index $chapterIndex. Chapter count: ${chapters.size}.")
|
||||
return
|
||||
}
|
||||
if (pageCache[chapterIndex] != null) {
|
||||
Timber.v("Trigger: Chapter $chapterIndex is already in cache. Ignoring.")
|
||||
return
|
||||
|
|
|
|||
|
|
@ -163,10 +163,12 @@ class ContentStyler(
|
|||
}
|
||||
|
||||
is SemanticMath -> {
|
||||
val svgContent = block.svgContent
|
||||
val nonBlankSvgContent = svgContent?.takeIf { it.isNotBlank() }
|
||||
val finalSvgContent = when {
|
||||
block.isFromMathJax || block.svgContent.isNullOrBlank() -> block.svgContent
|
||||
block.isFromMathJax || nonBlankSvgContent == null -> svgContent
|
||||
else -> {
|
||||
val themedSvg = applyThemeToSvg(block.svgContent)
|
||||
val themedSvg = applyThemeToSvg(nonBlankSvgContent)
|
||||
embedImagesInSvg(themedSvg)
|
||||
}
|
||||
}
|
||||
|
|
@ -452,11 +454,11 @@ class ContentStyler(
|
|||
}
|
||||
}
|
||||
|
||||
if (span.linkHref != null) {
|
||||
addStringAnnotation("URL", span.linkHref, span.start, span.end)
|
||||
span.linkHref?.let { linkHref ->
|
||||
addStringAnnotation("URL", linkHref, span.start, span.end)
|
||||
}
|
||||
if (span.elementId != null) {
|
||||
addStringAnnotation("ID", span.elementId, span.start, span.end)
|
||||
span.elementId?.let { elementId ->
|
||||
addStringAnnotation("ID", elementId, span.start, span.end)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -589,4 +591,4 @@ class ContentStyler(
|
|||
else -> if (isOrdered) "$counter. " else "• "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -27,47 +27,6 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* A centralized mapper for handling conversions between generic CSS font family names
|
||||
* and Compose's FontFamily objects.
|
||||
*/
|
||||
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
|
||||
)
|
||||
|
||||
/**
|
||||
* Converts a string name (e.g., "serif") to a Compose [FontFamily].
|
||||
*/
|
||||
fun nameToFontFamily(name: String): FontFamily? {
|
||||
return genericFontMap[name.trim().lowercase()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Compose [FontFamily] back to its primary string name for serialization.
|
||||
* Custom fonts are not serialized by name and will return null.
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCacheKeyForFont(bookId: String, fontPath: String): String {
|
||||
val identifier = "$bookId:$fontPath"
|
||||
val digest = MessageDigest.getInstance("MD5").digest(identifier.toByteArray())
|
||||
|
|
@ -158,4 +117,4 @@ fun loadFontFamilies(fontFaces: List<FontFaceInfo>, extractionPath: String): Map
|
|||
null
|
||||
}
|
||||
}.filterValues { it != null }.mapValues { it.value!! }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,670 +0,0 @@
|
|||
/*
|
||||
* 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 android.graphics.BitmapFactory
|
||||
import android.os.Build
|
||||
import timber.log.Timber
|
||||
import androidx.annotation.RequiresApi
|
||||
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
|
||||
import java.io.File
|
||||
import java.net.URLDecoder
|
||||
import java.nio.file.Paths
|
||||
|
||||
private val unsupportedPseudoElementRegex = Regex("::?(before|after|first-letter|first-line|marker|selection)", RegexOption.IGNORE_CASE)
|
||||
|
||||
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.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||
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()
|
||||
): List<SemanticBlock> {
|
||||
return SemanticHtmlParser(
|
||||
cssRules,
|
||||
textStyle,
|
||||
chapterAbsPath,
|
||||
extractionBasePath,
|
||||
density,
|
||||
fontFamilyMap,
|
||||
constraints,
|
||||
imageDimensionsCache,
|
||||
mathSvgCache
|
||||
).parse(html)
|
||||
}
|
||||
|
||||
/**
|
||||
* A stateful parser that holds the context for a single HTML-to-SemanticBlock conversion.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||
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 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()) {
|
||||
Timber.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 = loadFontFamilies(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) {
|
||||
Timber.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) {
|
||||
Timber.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 imageFile = resolveImagePath(href) ?: return null
|
||||
|
||||
val (width, height) = imageDimensionsCache[imageFile.absolutePath] ?: run {
|
||||
try {
|
||||
BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
.also { BitmapFactory.decodeFile(imageFile.absolutePath, it) }
|
||||
.let {
|
||||
Timber.tag("IMAGE_DIAG").d("Parsed file bounds: ${it.outWidth}x${it.outHeight} for ${imageFile.name}")
|
||||
Pair(it.outWidth.toFloat(), it.outHeight.toFloat())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("IMAGE_DIAG").e(e, "Failed to parse image bounds for ${imageFile.name}")
|
||||
Pair(null, null)
|
||||
}
|
||||
}
|
||||
|
||||
return SemanticImage(
|
||||
path = imageFile.absolutePath,
|
||||
altText = svgElement.selectFirst("title")?.text() ?: "Cover Image",
|
||||
intrinsicWidth = width,
|
||||
intrinsicHeight = height,
|
||||
style = style,
|
||||
elementId = svgElement.id().ifBlank { null },
|
||||
cfi = svgElement.getCfiPath(),
|
||||
blockIndex = nextBlockIndex++
|
||||
)
|
||||
}
|
||||
|
||||
Timber.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 imageFile = resolveImagePath(src) ?: return null
|
||||
|
||||
if (imageFile.extension.equals("svg", ignoreCase = true)) {
|
||||
return try {
|
||||
val svgContent = imageFile.readText()
|
||||
val svgElement = Jsoup.parseBodyFragment(svgContent).body().children().firstOrNull()
|
||||
svgElement?.let { parseSvgElementToSemantic(it, style) }
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to read SVG from <img> tag: ${imageFile.path}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val (width, height) = imageDimensionsCache[imageFile.absolutePath] ?: run {
|
||||
try {
|
||||
BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
.also { BitmapFactory.decodeFile(imageFile.absolutePath, it) }
|
||||
.let { Pair(it.outWidth.toFloat(), it.outHeight.toFloat()) }
|
||||
} catch (_: Exception) {
|
||||
Pair(null, null)
|
||||
}
|
||||
}
|
||||
|
||||
return SemanticImage(
|
||||
path = imageFile.absolutePath,
|
||||
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): File? {
|
||||
if (src.isBlank()) return null
|
||||
val decodedSrc = try { URLDecoder.decode(src, "UTF-8") } catch (_: Exception) { src }
|
||||
val parentPath = File(chapterAbsPath).parent ?: ""
|
||||
val relativePath = Paths.get(parentPath, decodedSrc).normalize().toString()
|
||||
val fromRelativeFile = File(extractionBasePath, relativePath)
|
||||
try {
|
||||
if (fromRelativeFile.exists()) return fromRelativeFile.canonicalFile
|
||||
val fromRootFile = File(extractionBasePath, decodedSrc)
|
||||
if (fromRootFile.exists()) return fromRootFile.canonicalFile
|
||||
} catch (e: java.io.IOException) {
|
||||
Timber.e(e, "Could not get canonical path for image at $src")
|
||||
return null
|
||||
}
|
||||
Timber.w("Image not found. Tried: ${fromRelativeFile.absolutePath} and ${File(extractionBasePath, decodedSrc).absolutePath}")
|
||||
return null
|
||||
}
|
||||
|
||||
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)?.absolutePath }
|
||||
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++)
|
||||
}
|
||||
}
|
||||
|
|
@ -115,7 +115,7 @@ class LocatorConverter(
|
|||
otherComplex = mergedOtherComplex
|
||||
)
|
||||
|
||||
val semanticBlocks = htmlToSemanticBlocks(
|
||||
val semanticBlocks = androidHtmlToSemanticBlocks(
|
||||
html = htmlToParse,
|
||||
cssRules = parsingCssRules,
|
||||
textStyle = TextStyle(),
|
||||
|
|
@ -381,4 +381,4 @@ class LocatorConverter(
|
|||
}
|
||||
return@withContext null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import androidx.compose.foundation.gestures.awaitEachGesture
|
|||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -147,7 +148,7 @@ import coil.compose.AsyncImage
|
|||
import coil.imageLoader
|
||||
import coil.request.ImageRequest.Builder
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.ReaderTexture
|
||||
import com.aryan.reader.loadReaderTextureBitmap
|
||||
import com.aryan.reader.countWords
|
||||
import com.aryan.reader.epub.EpubBook
|
||||
import com.aryan.reader.epubreader.HighlightColor
|
||||
|
|
@ -251,6 +252,12 @@ private fun headerFontScale(level: Int): Float = when (level) {
|
|||
else -> 1.0f
|
||||
}
|
||||
|
||||
private const val WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER = 1.2f
|
||||
|
||||
private fun paginationLineHeightMultiplierForWebViewSetting(multiplier: Float): Float {
|
||||
return if (abs(multiplier - 1.0f) < 0.001f) WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER else multiplier
|
||||
}
|
||||
|
||||
private fun createHeaderTextStyle(
|
||||
baseStyle: TextStyle,
|
||||
level: Int,
|
||||
|
|
@ -473,6 +480,51 @@ private fun computeImageRenderSizeDp(
|
|||
return with(density) { widthPx.toDp() to heightPx.toDp() }
|
||||
}
|
||||
|
||||
private fun imageBlockContentAlignment(style: BlockStyle): Alignment {
|
||||
return when {
|
||||
style.float == "right" || style.horizontalAlign == "right" || style.horizontalAlign == "end" -> Alignment.CenterEnd
|
||||
style.float == "left" || style.horizontalAlign == "left" || style.horizontalAlign == "start" -> Alignment.CenterStart
|
||||
else -> Alignment.Center
|
||||
}
|
||||
}
|
||||
|
||||
private fun tableCellImageModifier(
|
||||
block: ImageBlock,
|
||||
density: Density,
|
||||
imageSizeMultiplier: Float
|
||||
): Modifier {
|
||||
val baseModifier = if (block.style.width.isSpecified && block.style.width > 0.dp) {
|
||||
Modifier.width(block.style.width * imageSizeMultiplier)
|
||||
} else {
|
||||
Modifier.fillMaxWidth(imageSizeMultiplier.coerceIn(0f, 1f))
|
||||
}
|
||||
|
||||
val intrinsicWidth = block.intrinsicWidth
|
||||
val intrinsicHeight = block.intrinsicHeight
|
||||
val sizedModifier = if (
|
||||
intrinsicWidth != null &&
|
||||
intrinsicHeight != null &&
|
||||
intrinsicWidth > 0f &&
|
||||
intrinsicHeight > 0f
|
||||
) {
|
||||
baseModifier.aspectRatio(intrinsicWidth / intrinsicHeight)
|
||||
} else {
|
||||
baseModifier.height(
|
||||
if (block.expectedHeight > 0) {
|
||||
with(density) { (block.expectedHeight * imageSizeMultiplier).toDp() }
|
||||
} else {
|
||||
250.dp
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return if (block.style.maxWidth.isSpecified && block.style.maxWidth > 0.dp) {
|
||||
sizedModifier.widthIn(max = block.style.maxWidth * imageSizeMultiplier)
|
||||
} else {
|
||||
sizedModifier
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WrappingContentLayout(
|
||||
block: WrappingContentBlock,
|
||||
|
|
@ -682,6 +734,7 @@ fun PaginatedReaderScreen(
|
|||
paragraphGapMultiplier: Float,
|
||||
imageSizeMultiplier: Float,
|
||||
horizontalMarginMultiplier: Float,
|
||||
verticalMarginMultiplier: Float,
|
||||
fontFamily: FontFamily,
|
||||
textAlign: ReaderTextAlign,
|
||||
ttsHighlightInfo: TtsHighlightInfo?,
|
||||
|
|
@ -702,7 +755,8 @@ fun PaginatedReaderScreen(
|
|||
onHighlightDeleted: (String) -> Unit,
|
||||
activeHighlightPalette: List<HighlightColor>,
|
||||
onUpdatePalette: (Int, HighlightColor) -> Unit,
|
||||
activeTextureId: String? = null
|
||||
activeTextureId: String? = null,
|
||||
activeTextureAlpha: Float = 0.55f
|
||||
) {
|
||||
LaunchedEffect(userHighlights) {
|
||||
Timber.d("PaginatedReaderScreen: Received ${userHighlights.size} highlights.")
|
||||
|
|
@ -713,11 +767,7 @@ fun PaginatedReaderScreen(
|
|||
|
||||
val context = LocalContext.current
|
||||
val textureBitmap = remember(activeTextureId) {
|
||||
activeTextureId?.let { id ->
|
||||
ReaderTexture.entries.find { it.id == id }?.resId?.let { resId ->
|
||||
ImageBitmap.imageResource(context.resources, resId)
|
||||
}
|
||||
}
|
||||
loadReaderTextureBitmap(context, activeTextureId)
|
||||
}
|
||||
|
||||
val textureModifier = if (textureBitmap != null) {
|
||||
|
|
@ -725,13 +775,13 @@ fun PaginatedReaderScreen(
|
|||
val brush = ShaderBrush(
|
||||
ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)
|
||||
)
|
||||
drawRect(brush = brush, blendMode = BlendMode.Multiply, alpha = 0.6f)
|
||||
drawRect(brush = brush, blendMode = BlendMode.SrcOver, alpha = activeTextureAlpha.coerceIn(0f, 1f))
|
||||
}
|
||||
} else Modifier
|
||||
|
||||
var isNavigatingByLink by remember { mutableStateOf(false) }
|
||||
|
||||
BoxWithConstraints(modifier = modifier.fillMaxSize().background(effectiveBg).then(textureModifier)) {
|
||||
BoxWithConstraints(modifier = modifier.fillMaxSize().background(effectiveBg)) {
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
val baseTextStyle = MaterialTheme.typography.bodyLarge
|
||||
|
||||
|
|
@ -740,6 +790,7 @@ fun PaginatedReaderScreen(
|
|||
var debouncedParagraphGapMult by remember { mutableFloatStateOf(paragraphGapMultiplier) }
|
||||
var debouncedImageSizeMult by remember { mutableFloatStateOf(imageSizeMultiplier) }
|
||||
var debouncedHorizontalMarginMult by remember { mutableFloatStateOf(horizontalMarginMultiplier) }
|
||||
var debouncedVerticalMarginMult by remember { mutableFloatStateOf(verticalMarginMultiplier) }
|
||||
var debouncedFontFamily by remember { mutableStateOf(fontFamily) }
|
||||
var debouncedTextAlign by remember { mutableStateOf(textAlign) }
|
||||
|
||||
|
|
@ -781,7 +832,7 @@ fun PaginatedReaderScreen(
|
|||
debouncedFontFamily
|
||||
) {
|
||||
val adjustedFontSize = baseTextStyle.fontSize * debouncedFontSizeMult
|
||||
val adjustedLineHeight = adjustedFontSize * debouncedLineHeightMult
|
||||
val adjustedLineHeight = adjustedFontSize * paginationLineHeightMultiplierForWebViewSetting(debouncedLineHeightMult)
|
||||
|
||||
baseTextStyle.copy(
|
||||
color = effectiveText,
|
||||
|
|
@ -810,12 +861,13 @@ fun PaginatedReaderScreen(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(fontSizeMultiplier, lineHeightMultiplier, paragraphGapMultiplier, imageSizeMultiplier, horizontalMarginMultiplier, fontFamily, textAlign) {
|
||||
LaunchedEffect(fontSizeMultiplier, lineHeightMultiplier, paragraphGapMultiplier, imageSizeMultiplier, horizontalMarginMultiplier, verticalMarginMultiplier, fontFamily, textAlign) {
|
||||
if (fontSizeMultiplier != debouncedFontSizeMult ||
|
||||
lineHeightMultiplier != debouncedLineHeightMult ||
|
||||
paragraphGapMultiplier != debouncedParagraphGapMult ||
|
||||
imageSizeMultiplier != debouncedImageSizeMult ||
|
||||
horizontalMarginMultiplier != debouncedHorizontalMarginMult ||
|
||||
verticalMarginMultiplier != debouncedVerticalMarginMult ||
|
||||
fontFamily != debouncedFontFamily ||
|
||||
textAlign != debouncedTextAlign
|
||||
) {
|
||||
|
|
@ -836,6 +888,7 @@ fun PaginatedReaderScreen(
|
|||
debouncedParagraphGapMult = paragraphGapMultiplier
|
||||
debouncedImageSizeMult = imageSizeMultiplier
|
||||
debouncedHorizontalMarginMult = horizontalMarginMultiplier
|
||||
debouncedVerticalMarginMult = verticalMarginMultiplier
|
||||
debouncedFontFamily = fontFamily
|
||||
debouncedTextAlign = textAlign
|
||||
Timber.d("Debounce complete. Applying new format settings.")
|
||||
|
|
@ -851,8 +904,28 @@ fun PaginatedReaderScreen(
|
|||
}
|
||||
|
||||
val density = LocalDensity.current
|
||||
val horizontalPadding = 16.dp * debouncedHorizontalMarginMult
|
||||
val verticalPadding = 16.dp
|
||||
val requestedHorizontalPadding = 16.dp * debouncedHorizontalMarginMult
|
||||
val requestedVerticalPadding = 16.dp * debouncedVerticalMarginMult
|
||||
val effectiveReaderPadding =
|
||||
remember(this.constraints, density, requestedHorizontalPadding, requestedVerticalPadding) {
|
||||
val requestedHorizontalPaddingPx = with(density) { requestedHorizontalPadding.roundToPx() }
|
||||
val requestedVerticalPaddingPx = with(density) { requestedVerticalPadding.roundToPx() }
|
||||
val minReadableWidthPx = with(density) { 96.dp.roundToPx() }
|
||||
.coerceAtMost(this.constraints.maxWidth)
|
||||
val minReadableHeightPx = with(density) { 160.dp.roundToPx() }
|
||||
.coerceAtMost(this.constraints.maxHeight)
|
||||
val horizontalPaddingPx = requestedHorizontalPaddingPx.coerceAtMost(
|
||||
((this.constraints.maxWidth - minReadableWidthPx) / 2).coerceAtLeast(0)
|
||||
)
|
||||
val verticalPaddingPx = requestedVerticalPaddingPx.coerceAtMost(
|
||||
((this.constraints.maxHeight - minReadableHeightPx) / 2).coerceAtLeast(0)
|
||||
)
|
||||
with(density) {
|
||||
horizontalPaddingPx.toDp() to verticalPaddingPx.toDp()
|
||||
}
|
||||
}
|
||||
val horizontalPadding = effectiveReaderPadding.first
|
||||
val verticalPadding = effectiveReaderPadding.second
|
||||
|
||||
val textConstraints =
|
||||
remember(this.constraints, density, horizontalPadding, verticalPadding) {
|
||||
|
|
@ -860,9 +933,9 @@ fun PaginatedReaderScreen(
|
|||
val verticalPaddingPx = with(density) { verticalPadding.roundToPx() }
|
||||
val finalConstraints = this.constraints.copy(
|
||||
minWidth = 0,
|
||||
maxWidth = this.constraints.maxWidth - (2 * horizontalPaddingPx),
|
||||
maxWidth = (this.constraints.maxWidth - (2 * horizontalPaddingPx)).coerceAtLeast(1),
|
||||
minHeight = 0,
|
||||
maxHeight = this.constraints.maxHeight - (2 * verticalPaddingPx)
|
||||
maxHeight = (this.constraints.maxHeight - (2 * verticalPaddingPx)).coerceAtLeast(1)
|
||||
)
|
||||
finalConstraints
|
||||
}
|
||||
|
|
@ -950,7 +1023,8 @@ fun PaginatedReaderScreen(
|
|||
mathMLRenderer = mathMLRenderer,
|
||||
userTextAlign = userTextAlign,
|
||||
paragraphGapMultiplier = debouncedParagraphGapMult,
|
||||
imageSizeMultiplier = debouncedImageSizeMult
|
||||
imageSizeMultiplier = debouncedImageSizeMult,
|
||||
verticalMarginMultiplier = debouncedVerticalMarginMult
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1168,7 +1242,10 @@ fun PaginatedReaderScreen(
|
|||
isDarkTheme = isDarkTheme,
|
||||
activeHighlightPalette = activeHighlightPalette,
|
||||
onUpdatePalette = onUpdatePalette,
|
||||
effectiveText = effectiveText
|
||||
effectiveText = effectiveText,
|
||||
pageTextureModifier = if (isPageTurnAnimationEnabled) Modifier else textureModifier,
|
||||
pageTextureBitmap = textureBitmap,
|
||||
pageTextureAlpha = activeTextureAlpha.coerceIn(0f, 1f)
|
||||
)
|
||||
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
|
|
@ -1989,7 +2066,10 @@ internal fun PaginatedReaderContent(
|
|||
onHighlightDeleted: (String) -> Unit,
|
||||
activeHighlightPalette: List<HighlightColor>,
|
||||
onUpdatePalette: (Int, HighlightColor) -> Unit,
|
||||
isDarkTheme: Boolean
|
||||
isDarkTheme: Boolean,
|
||||
pageTextureModifier: Modifier = Modifier,
|
||||
pageTextureBitmap: ImageBitmap? = null,
|
||||
pageTextureAlpha: Float = 0f
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val density = LocalDensity.current
|
||||
|
|
@ -2130,7 +2210,9 @@ internal fun PaginatedReaderContent(
|
|||
pageIndex,
|
||||
effectiveBg,
|
||||
isDarkTheme,
|
||||
pageTurnTouchY
|
||||
pageTurnTouchY,
|
||||
pageTextureBitmap,
|
||||
pageTextureAlpha
|
||||
)
|
||||
} else Modifier
|
||||
|
||||
|
|
@ -2284,7 +2366,7 @@ internal fun PaginatedReaderContent(
|
|||
pendingCrossPageSelection = null
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize().then(pageModifier)) {
|
||||
Box(modifier = Modifier.fillMaxSize().background(effectiveBg).then(pageTextureModifier).then(pageModifier)) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Box(modifier = Modifier.fillMaxSize().pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
|
|
@ -2784,12 +2866,14 @@ internal fun PaginatedReaderContent(
|
|||
val markerAreaModifier =
|
||||
Modifier.width(32.dp)
|
||||
.padding(end = 8.dp)
|
||||
val itemMarkerImage = block.itemMarkerImage
|
||||
val itemMarker = block.itemMarker
|
||||
|
||||
if (block.itemMarkerImage != null) {
|
||||
if (itemMarkerImage != null) {
|
||||
val imageRequest =
|
||||
Builder(LocalContext.current).data(
|
||||
File(
|
||||
block.itemMarkerImage
|
||||
itemMarkerImage
|
||||
)
|
||||
).crossfade(true).build()
|
||||
val imageSize = with(density) {
|
||||
|
|
@ -2805,9 +2889,9 @@ internal fun PaginatedReaderContent(
|
|||
alignment = Alignment.CenterEnd,
|
||||
contentScale = ContentScale.FillHeight
|
||||
)
|
||||
} else if (block.itemMarker != null) {
|
||||
} else if (itemMarker != null) {
|
||||
Text(
|
||||
text = block.itemMarker,
|
||||
text = itemMarker,
|
||||
style = textStyle.copy(
|
||||
textAlign = TextAlign.End
|
||||
),
|
||||
|
|
@ -3017,10 +3101,12 @@ internal fun PaginatedReaderContent(
|
|||
}
|
||||
|
||||
is MathBlock -> {
|
||||
val svgContent = block.svgContent?.takeIf { it.isNotBlank() }
|
||||
Timber.d(
|
||||
"PaginatedReader: Rendering MathBlock. Alt: '${block.altText}', Has SVG: ${!block.svgContent.isNullOrBlank()}"
|
||||
"PaginatedReader: Rendering MathBlock. Alt: '${block.altText}', Has SVG: ${svgContent != null}"
|
||||
)
|
||||
if (!block.svgContent.isNullOrBlank()) {
|
||||
if (svgContent != null) {
|
||||
val nonBlankSvgContent = svgContent
|
||||
BoxWithConstraints(
|
||||
modifier = paddingModifier
|
||||
) {
|
||||
|
|
@ -3104,7 +3190,7 @@ internal fun PaginatedReaderContent(
|
|||
val imageRequest =
|
||||
Builder(LocalContext.current).data(
|
||||
SvgData(
|
||||
block.svgContent
|
||||
nonBlankSvgContent
|
||||
)
|
||||
).listener(
|
||||
onError = { _, result ->
|
||||
|
|
@ -3189,7 +3275,10 @@ internal fun PaginatedReaderContent(
|
|||
)
|
||||
}).crossfade(true).build()
|
||||
|
||||
BoxWithConstraints(modifier = paddingModifier) {
|
||||
BoxWithConstraints(
|
||||
modifier = paddingModifier,
|
||||
contentAlignment = imageBlockContentAlignment(style)
|
||||
) {
|
||||
val scaledSize = computeImageRenderSizeDp(
|
||||
block = block,
|
||||
density = density,
|
||||
|
|
@ -3357,9 +3446,10 @@ internal fun PaginatedReaderContent(
|
|||
Row(
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
if (blockInCell.itemMarker != null) {
|
||||
val itemMarker = blockInCell.itemMarker
|
||||
if (itemMarker != null) {
|
||||
Text(
|
||||
text = blockInCell.itemMarker,
|
||||
text = itemMarker,
|
||||
style = cellTextStyle,
|
||||
modifier = Modifier.padding(
|
||||
end = 4.dp
|
||||
|
|
@ -3390,40 +3480,23 @@ internal fun PaginatedReaderContent(
|
|||
}
|
||||
|
||||
is ImageBlock -> {
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||
val scaledSize = computeImageRenderSizeDp(
|
||||
AsyncImage(
|
||||
model = Builder(
|
||||
LocalContext.current
|
||||
).data(
|
||||
File(
|
||||
blockInCell.path
|
||||
)
|
||||
)
|
||||
.build(),
|
||||
contentDescription = blockInCell.altText,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = tableCellImageModifier(
|
||||
block = blockInCell,
|
||||
density = density,
|
||||
maxWidthDp = maxWidth,
|
||||
imageSizeMultiplier = imageSizeMultiplier
|
||||
)
|
||||
val imageModifier = Modifier.then(
|
||||
if (scaledSize != null) {
|
||||
Modifier.width(scaledSize.first).height(scaledSize.second)
|
||||
} else {
|
||||
Modifier.fillMaxWidth().then(
|
||||
if (blockInCell.expectedHeight > 0) {
|
||||
Modifier.height(with(density) { (blockInCell.expectedHeight * imageSizeMultiplier).toDp() })
|
||||
} else {
|
||||
Modifier.height(250.dp)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
AsyncImage(
|
||||
model = Builder(
|
||||
LocalContext.current
|
||||
).data(
|
||||
File(
|
||||
blockInCell.path
|
||||
)
|
||||
)
|
||||
.build(),
|
||||
contentDescription = blockInCell.altText,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = imageModifier
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is TextContentBlock -> {
|
||||
|
|
@ -4101,10 +4174,12 @@ private fun RenderFlexChildBlock(
|
|||
val markerAreaModifier = Modifier
|
||||
.width(32.dp)
|
||||
.padding(end = 8.dp)
|
||||
val itemMarkerImage = childBlock.itemMarkerImage
|
||||
val itemMarker = childBlock.itemMarker
|
||||
|
||||
if (childBlock.itemMarkerImage != null) {
|
||||
if (itemMarkerImage != null) {
|
||||
val imageRequest =
|
||||
Builder(LocalContext.current).data(File(childBlock.itemMarkerImage))
|
||||
Builder(LocalContext.current).data(File(itemMarkerImage))
|
||||
.crossfade(true).build()
|
||||
val imageSize = with(density) { (textStyle.fontSize.value * 0.8f).sp.toDp() }
|
||||
|
||||
|
|
@ -4115,9 +4190,9 @@ private fun RenderFlexChildBlock(
|
|||
alignment = Alignment.CenterEnd,
|
||||
contentScale = ContentScale.FillHeight
|
||||
)
|
||||
} else if (childBlock.itemMarker != null) {
|
||||
} else if (itemMarker != null) {
|
||||
Text(
|
||||
text = childBlock.itemMarker,
|
||||
text = itemMarker,
|
||||
style = textStyle.copy(textAlign = TextAlign.End),
|
||||
modifier = markerAreaModifier
|
||||
)
|
||||
|
|
@ -4160,7 +4235,7 @@ private fun RenderFlexChildBlock(
|
|||
ColorFilter.colorMatrix(ColorMatrix(matrix))
|
||||
} else null
|
||||
|
||||
BoxWithConstraints {
|
||||
BoxWithConstraints(contentAlignment = imageBlockContentAlignment(style)) {
|
||||
val scaledSize = computeImageRenderSizeDp(
|
||||
block = childBlock,
|
||||
density = density,
|
||||
|
|
@ -4278,37 +4353,20 @@ private fun RenderFlexChildBlock(
|
|||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
} else if (blockInCell is ImageBlock) {
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||
val scaledSize = computeImageRenderSizeDp(
|
||||
AsyncImage(
|
||||
model = Builder(LocalContext.current).data(
|
||||
File(
|
||||
blockInCell.path
|
||||
)
|
||||
).build(),
|
||||
contentDescription = blockInCell.altText,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = tableCellImageModifier(
|
||||
block = blockInCell,
|
||||
density = density,
|
||||
maxWidthDp = maxWidth,
|
||||
imageSizeMultiplier = imageSizeMultiplier
|
||||
)
|
||||
val imageModifier = Modifier.then(
|
||||
if (scaledSize != null) {
|
||||
Modifier.width(scaledSize.first).height(scaledSize.second)
|
||||
} else {
|
||||
Modifier.fillMaxWidth().then(
|
||||
if (blockInCell.expectedHeight > 0) {
|
||||
Modifier.height(with(density) { (blockInCell.expectedHeight * imageSizeMultiplier).toDp() })
|
||||
} else {
|
||||
Modifier.height(250.dp)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
AsyncImage(
|
||||
model = Builder(LocalContext.current).data(
|
||||
File(
|
||||
blockInCell.path
|
||||
)
|
||||
).build(),
|
||||
contentDescription = blockInCell.altText,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = imageModifier
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4332,7 +4390,9 @@ private fun Modifier.realisticBookPage(
|
|||
pageIndex: Int,
|
||||
paperColor: Color,
|
||||
isDarkTheme: Boolean,
|
||||
touchY: Float?
|
||||
touchY: Float?,
|
||||
textureBitmap: ImageBitmap? = null,
|
||||
textureAlpha: Float = 0f
|
||||
): Modifier = composed {
|
||||
|
||||
val frontPath = remember { Path() }
|
||||
|
|
@ -4360,9 +4420,19 @@ private fun Modifier.realisticBookPage(
|
|||
.drawWithContent {
|
||||
val drawStart = System.nanoTime()
|
||||
val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
|
||||
fun drawPaperBackground() {
|
||||
drawRect(color = paperColor)
|
||||
if (textureBitmap != null && textureAlpha > 0f) {
|
||||
drawRect(
|
||||
brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)),
|
||||
blendMode = BlendMode.SrcOver,
|
||||
alpha = textureAlpha
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (abs(pageOffset) < 0.001f) {
|
||||
drawRect(color = paperColor)
|
||||
drawPaperBackground()
|
||||
drawContent()
|
||||
}
|
||||
else if (pageOffset < 0f && pageOffset > -1f) {
|
||||
|
|
@ -4423,7 +4493,7 @@ private fun Modifier.realisticBookPage(
|
|||
frontPath.close()
|
||||
|
||||
clipPath(frontPath) {
|
||||
drawRect(color = paperColor)
|
||||
drawPaperBackground()
|
||||
this@drawWithContent.drawContent()
|
||||
}
|
||||
|
||||
|
|
@ -4466,6 +4536,15 @@ private fun Modifier.realisticBookPage(
|
|||
clipRect(0f, 0f, w, h) {
|
||||
clipPath(frontPath) {
|
||||
drawPath(reflectedScreenPath, color = paperColor)
|
||||
if (textureBitmap != null && textureAlpha > 0f) {
|
||||
clipPath(reflectedScreenPath) {
|
||||
drawRect(
|
||||
brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)),
|
||||
blendMode = BlendMode.SrcOver,
|
||||
alpha = textureAlpha
|
||||
)
|
||||
}
|
||||
}
|
||||
val flapTint = if (isDarkTheme) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f)
|
||||
drawPath(reflectedScreenPath, color = flapTint)
|
||||
|
||||
|
|
@ -4493,12 +4572,12 @@ private fun Modifier.realisticBookPage(
|
|||
}
|
||||
|
||||
} else {
|
||||
drawRect(color = paperColor)
|
||||
drawPaperBackground()
|
||||
drawContent()
|
||||
}
|
||||
}
|
||||
else {
|
||||
drawRect(color = paperColor)
|
||||
drawPaperBackground()
|
||||
drawContent()
|
||||
}
|
||||
|
||||
|
|
@ -4515,10 +4594,14 @@ fun Modifier.drawCssBorders(
|
|||
blockStyle: BlockStyle,
|
||||
@Suppress("unused") density: Density
|
||||
): Modifier = this.drawBehind {
|
||||
val topWidth = blockStyle.borderTop?.width?.toPx() ?: 0f
|
||||
val rightWidth = blockStyle.borderRight?.width?.toPx() ?: 0f
|
||||
val bottomWidth = blockStyle.borderBottom?.width?.toPx() ?: 0f
|
||||
val leftWidth = blockStyle.borderLeft?.width?.toPx() ?: 0f
|
||||
val borderTop = blockStyle.borderTop
|
||||
val borderRight = blockStyle.borderRight
|
||||
val borderBottom = blockStyle.borderBottom
|
||||
val borderLeft = blockStyle.borderLeft
|
||||
val topWidth = borderTop?.width?.toPx() ?: 0f
|
||||
val rightWidth = borderRight?.width?.toPx() ?: 0f
|
||||
val bottomWidth = borderBottom?.width?.toPx() ?: 0f
|
||||
val leftWidth = borderLeft?.width?.toPx() ?: 0f
|
||||
|
||||
val tlRadius = blockStyle.borderTopLeftRadius.toPx()
|
||||
val trRadius = blockStyle.borderTopRightRadius.toPx()
|
||||
|
|
@ -4550,9 +4633,9 @@ fun Modifier.drawCssBorders(
|
|||
}
|
||||
|
||||
// TOP
|
||||
if (topWidth > 0f && blockStyle.borderTop != null) {
|
||||
val color = blockStyle.borderTop.color
|
||||
val effect = getPathEffect(blockStyle.borderTop.style, topWidth)
|
||||
if (topWidth > 0f && borderTop != null) {
|
||||
val color = borderTop.color
|
||||
val effect = getPathEffect(borderTop.style, topWidth)
|
||||
val offset = topWidth / 2f
|
||||
|
||||
val startX = if (tlRadius > 0) tlRadius else 0f
|
||||
|
|
@ -4568,9 +4651,9 @@ fun Modifier.drawCssBorders(
|
|||
}
|
||||
|
||||
// BOTTOM
|
||||
if (bottomWidth > 0f && blockStyle.borderBottom != null) {
|
||||
val color = blockStyle.borderBottom.color
|
||||
val effect = getPathEffect(blockStyle.borderBottom.style, bottomWidth)
|
||||
if (bottomWidth > 0f && borderBottom != null) {
|
||||
val color = borderBottom.color
|
||||
val effect = getPathEffect(borderBottom.style, bottomWidth)
|
||||
val offset = size.height - (bottomWidth / 2f)
|
||||
|
||||
val startX = if (blRadius > 0) blRadius else 0f
|
||||
|
|
@ -4586,9 +4669,9 @@ fun Modifier.drawCssBorders(
|
|||
}
|
||||
|
||||
// LEFT
|
||||
if (leftWidth > 0f && blockStyle.borderLeft != null) {
|
||||
val color = blockStyle.borderLeft.color
|
||||
val effect = getPathEffect(blockStyle.borderLeft.style, leftWidth)
|
||||
if (leftWidth > 0f && borderLeft != null) {
|
||||
val color = borderLeft.color
|
||||
val effect = getPathEffect(borderLeft.style, leftWidth)
|
||||
val offset = leftWidth / 2f
|
||||
|
||||
val startY = if (tlRadius > 0) tlRadius else 0f
|
||||
|
|
@ -4604,9 +4687,9 @@ fun Modifier.drawCssBorders(
|
|||
}
|
||||
|
||||
// RIGHT
|
||||
if (rightWidth > 0f && blockStyle.borderRight != null) {
|
||||
val color = blockStyle.borderRight.color
|
||||
val effect = getPathEffect(blockStyle.borderRight.style, rightWidth)
|
||||
if (rightWidth > 0f && borderRight != null) {
|
||||
val color = borderRight.color
|
||||
val effect = getPathEffect(borderRight.style, rightWidth)
|
||||
val offset = size.width - (rightWidth / 2f)
|
||||
|
||||
val startY = if (trRadius > 0) trRadius else 0f
|
||||
|
|
@ -4621,9 +4704,9 @@ fun Modifier.drawCssBorders(
|
|||
)
|
||||
}
|
||||
|
||||
if (tlRadius > 0f && topWidth > 0f && leftWidth > 0f && blockStyle.borderTop != null) {
|
||||
if (tlRadius > 0f && topWidth > 0f && leftWidth > 0f && borderTop != null) {
|
||||
drawArc(
|
||||
color = blockStyle.borderTop.color,
|
||||
color = borderTop.color,
|
||||
startAngle = 180f, sweepAngle = 90f,
|
||||
useCenter = false,
|
||||
topLeft = Offset(leftWidth/2f, topWidth/2f),
|
||||
|
|
@ -4632,9 +4715,9 @@ fun Modifier.drawCssBorders(
|
|||
)
|
||||
}
|
||||
|
||||
if (trRadius > 0f && topWidth > 0f && rightWidth > 0f && blockStyle.borderTop != null) {
|
||||
if (trRadius > 0f && topWidth > 0f && rightWidth > 0f && borderTop != null) {
|
||||
drawArc(
|
||||
color = blockStyle.borderTop.color,
|
||||
color = borderTop.color,
|
||||
startAngle = 270f, sweepAngle = 90f,
|
||||
useCenter = false,
|
||||
topLeft = Offset(size.width - (trRadius * 2) + (rightWidth/2f), topWidth/2f),
|
||||
|
|
@ -4643,9 +4726,9 @@ fun Modifier.drawCssBorders(
|
|||
)
|
||||
}
|
||||
|
||||
if (brRadius > 0f && bottomWidth > 0f && rightWidth > 0f && blockStyle.borderBottom != null) {
|
||||
if (brRadius > 0f && bottomWidth > 0f && rightWidth > 0f && borderBottom != null) {
|
||||
drawArc(
|
||||
color = blockStyle.borderBottom.color,
|
||||
color = borderBottom.color,
|
||||
startAngle = 0f, sweepAngle = 90f,
|
||||
useCenter = false,
|
||||
topLeft = Offset(size.width - (brRadius * 2) + (rightWidth/2f), size.height - (brRadius * 2) + (bottomWidth/2f)),
|
||||
|
|
@ -4654,9 +4737,9 @@ fun Modifier.drawCssBorders(
|
|||
)
|
||||
}
|
||||
|
||||
if (blRadius > 0f && bottomWidth > 0f && leftWidth > 0f && blockStyle.borderBottom != null) {
|
||||
if (blRadius > 0f && bottomWidth > 0f && leftWidth > 0f && borderBottom != null) {
|
||||
drawArc(
|
||||
color = blockStyle.borderBottom.color,
|
||||
color = borderBottom.color,
|
||||
startAngle = 90f, sweepAngle = 90f,
|
||||
useCenter = false,
|
||||
topLeft = Offset(leftWidth/2f, size.height - (blRadius * 2) + (bottomWidth/2f)),
|
||||
|
|
|
|||
|
|
@ -1,412 +0,0 @@
|
|||
/*
|
||||
* 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>
|
||||
)
|
||||
|
|
@ -145,7 +145,8 @@ class PaginatedReaderViewModel : ViewModel() {
|
|||
mathMLRenderer = mathMLRenderer,
|
||||
userTextAlign = null,
|
||||
paragraphGapMultiplier = paragraphGapMultiplier,
|
||||
imageSizeMultiplier = 1.0f
|
||||
imageSizeMultiplier = 1.0f,
|
||||
verticalMarginMultiplier = 1.0f
|
||||
)
|
||||
paginator = newPaginator
|
||||
|
||||
|
|
|
|||
|
|
@ -1,202 +0,0 @@
|
|||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
/*
|
||||
* 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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
/*
|
||||
* 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()
|
||||
}
|
||||
|
|
@ -198,7 +198,7 @@ abstract class BookCacheDao {
|
|||
ConfigurationCache::class,
|
||||
AnchorIndexEntry::class
|
||||
],
|
||||
version = 8,
|
||||
version = 10,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class BookCacheDatabase : RoomDatabase() {
|
||||
|
|
@ -222,4 +222,4 @@ abstract class BookCacheDatabase : RoomDatabase() {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import androidx.room.ForeignKey
|
|||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
const val LATEST_PROCESSING_VERSION = 8
|
||||
const val LATEST_PROCESSING_VERSION = 10
|
||||
|
||||
@Entity(tableName = "processed_books")
|
||||
data class ProcessedBook(
|
||||
|
|
@ -130,4 +130,4 @@ data class ConfigurationCache(
|
|||
val bookId: String,
|
||||
val configHash: Int,
|
||||
val chapterPageCounts: String
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ import com.aryan.reader.paginatedreader.FontFaceInfo
|
|||
import com.aryan.reader.paginatedreader.MathMLRenderer
|
||||
import com.aryan.reader.paginatedreader.OptimizedCssRules
|
||||
import com.aryan.reader.paginatedreader.RenderResult
|
||||
import com.aryan.reader.paginatedreader.htmlToSemanticBlocks
|
||||
import com.aryan.reader.paginatedreader.androidHtmlToSemanticBlocks
|
||||
import com.aryan.reader.paginatedreader.loadFontFamilies
|
||||
import com.aryan.reader.paginatedreader.semanticBlockModule
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -277,7 +277,7 @@ class BookProcessingWorker(
|
|||
Timber.d("Chapter $index (Background Worker): Processed HTML contains <math-placeholder>: ${processedHtml.contains("math-placeholder")}")
|
||||
|
||||
|
||||
val semanticBlocks = htmlToSemanticBlocks(
|
||||
val semanticBlocks = androidHtmlToSemanticBlocks(
|
||||
html = processedHtml,
|
||||
cssRules = lightThemeCssRules,
|
||||
textStyle = textStyle,
|
||||
|
|
@ -371,4 +371,4 @@ class BookProcessingWorker(
|
|||
blocks.forEach { walk(it) }
|
||||
return anchors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,513 +0,0 @@
|
|||
/*
|
||||
* 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue