Initial commit

This commit is contained in:
Aryan 2026-02-24 17:37:40 +05:30
commit 6072b2ba29
844 changed files with 220532 additions and 0 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,493 @@
// ContentStyler.kt
package com.aryan.reader.paginatedreader
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.AnnotatedString
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
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.withStyle
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.isUnspecified
import androidx.compose.ui.unit.sp
import org.jsoup.Jsoup
import java.io.File
import java.net.URLDecoder
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
class ContentStyler(
private val baseTextStyle: TextStyle,
private val fontFamilyMap: Map<String, FontFamily>,
private val density: Density,
private val isDarkTheme: Boolean,
private val chapterAbsPath: String,
private val extractionBasePath: String,
private val userTextAlign: TextAlign?
) {
fun style(semanticBlocks: List<SemanticBlock>): List<ContentBlock> {
return groupFloatingBlocks(semanticBlocks.mapNotNull { styleBlock(it) })
}
// ADD this function to group floating blocks, similar to the original parser
private fun groupFloatingBlocks(blocks: List<ContentBlock>): List<ContentBlock> {
if (blocks.isEmpty()) return emptyList()
val result = mutableListOf<ContentBlock>()
val processingQueue = blocks.toMutableList()
while (processingQueue.isNotEmpty()) {
val currentBlock = processingQueue.removeAt(0)
val floatDirection = (currentBlock as? ImageBlock)?.style?.float
if (currentBlock is ImageBlock && floatDirection in listOf("left", "right")) {
val floatedImage = currentBlock
val paragraphsToWrap = mutableListOf<ParagraphBlock>()
while (processingQueue.isNotEmpty()) {
val nextBlock = processingQueue.first()
val nextBlockStyle = nextBlock.style
val shouldClear = nextBlockStyle.clear in listOf("both", floatDirection)
if (nextBlock is ParagraphBlock && !shouldClear) {
val paragraph = processingQueue.removeAt(0) as ParagraphBlock
paragraphsToWrap.add(paragraph)
} else {
break
}
}
val wrappingBlock = WrappingContentBlock(
floatedImage,
paragraphsToWrap,
elementId = floatedImage.elementId,
cfi = floatedImage.cfi,
blockIndex = floatedImage.blockIndex
)
result.add(wrappingBlock)
} else {
result.add(currentBlock)
}
}
return result
}
private fun styleBlock(block: SemanticBlock): ContentBlock? {
val themedStyle = applyThemeToStyle(block.style)
return when (block) {
is SemanticParagraph -> {
val computedTextAlign = userTextAlign ?: themedStyle.paragraphStyle.textAlign
ParagraphBlock(
content = buildAnnotatedString(block, themedStyle),
textAlign = computedTextAlign,
style = themedStyle.blockStyle,
elementId = block.elementId,
cfi = block.cfi,
startCharOffsetInSource = block.startCharOffsetInSource,
blockIndex = block.blockIndex
)
}
is SemanticHeader -> HeaderBlock(
level = block.level,
content = buildAnnotatedString(block, themedStyle),
textAlign = themedStyle.paragraphStyle.textAlign,
style = themedStyle.blockStyle,
elementId = block.elementId,
cfi = block.cfi,
startCharOffsetInSource = block.startCharOffsetInSource,
blockIndex = block.blockIndex
)
is SemanticImage -> {
var finalBlockStyle = themedStyle.blockStyle
if (themedStyle.paragraphStyle.textAlign == TextAlign.Center) {
finalBlockStyle = finalBlockStyle.copy(horizontalAlign = "center")
}
val shouldInvert = themedStyle.blockStyle.filter == "invert(100%)"
ImageBlock(
path = block.path,
altText = block.altText,
intrinsicWidth = block.intrinsicWidth,
intrinsicHeight = block.intrinsicHeight,
style = finalBlockStyle,
elementId = block.elementId,
cfi = block.cfi,
invertOnDarkTheme = shouldInvert,
blockIndex = block.blockIndex
)
}
is SemanticMath -> {
val finalSvgContent = when {
block.isFromMathJax || block.svgContent.isNullOrBlank() -> block.svgContent
else -> {
val themedSvg = applyThemeToSvg(block.svgContent)
embedImagesInSvg(themedSvg)
}
}
MathBlock(
svgContent = finalSvgContent,
altText = block.altText,
style = themedStyle.blockStyle,
elementId = block.elementId,
cfi = block.cfi,
svgWidth = block.svgWidth,
svgHeight = block.svgHeight,
svgViewBox = block.svgViewBox,
isFromMathJax = block.isFromMathJax,
blockIndex = block.blockIndex
)
}
is SemanticList -> styleList(block, themedStyle)
is SemanticTable -> styleTable(block, themedStyle)
is SemanticSpacer -> {
val height = if (block.isExplicitLineBreak) with(density) { baseTextStyle.fontSize.toDp() } else 8.dp
SpacerBlock(height = height, style = themedStyle.blockStyle, elementId = block.elementId, cfi = block.cfi, blockIndex = block.blockIndex)
}
is SemanticFlexContainer -> FlexContainerBlock(
children = block.children.mapNotNull { styleBlock(it) },
style = themedStyle.blockStyle,
elementId = block.elementId,
cfi = block.cfi,
blockIndex = block.blockIndex
)
is SemanticWrappingBlock -> {
val styledImage = styleBlock(block.floatedImage) as? ImageBlock
val styledParagraphs = block.paragraphsToWrap.mapNotNull { styleBlock(it) as? ParagraphBlock }
if (styledImage != null) {
WrappingContentBlock(
floatedImage = styledImage,
paragraphsToWrap = styledParagraphs,
elementId = block.elementId,
cfi = block.cfi,
blockIndex = block.blockIndex
)
} else {
null
}
}
else -> {
Timber.w("Unsupported or misplaced SemanticBlock type encountered: ${block::class.java.simpleName}")
null
}
}
}
private fun applyThemeToStyle(style: CssStyle): CssStyle {
val newSpanStyle = style.spanStyle.let { original ->
val newColor = if (original.color.isSpecified) {
CssParser.adaptColorForTheme(original.color, isDarkTheme, isBackground = false)
} else {
original.color
}
original.copy(color = newColor)
}
val newBlockStyle = style.blockStyle.let { original ->
val newBgColor = if (original.backgroundColor.isSpecified) {
CssParser.adaptColorForTheme(original.backgroundColor, isDarkTheme, isBackground = true)
} else {
original.backgroundColor
}
val newBorder = original.border?.let {
val newBorderColor = CssParser.adaptColorForTheme(it.color, isDarkTheme, isBackground = false)
it.copy(color = newBorderColor)
}
original.copy(backgroundColor = newBgColor, border = newBorder)
}
return style.copy(spanStyle = newSpanStyle, blockStyle = newBlockStyle)
}
private fun embedImagesInSvg(svgContent: String): String {
try {
val svgDocument = Jsoup.parseBodyFragment(svgContent)
val svgElement = svgDocument.body().children().firstOrNull() ?: return svgContent
svgElement.select("image").forEach { imageElement ->
val href = imageElement.attr("href").ifBlank { imageElement.attr("xlink:href") }
if (href.isNotBlank() && !href.startsWith("data:")) {
resolveImagePath(href)?.let { imageFile ->
try {
val imageBytes = imageFile.readBytes()
val mimeType = when (imageFile.extension.lowercase()) {
"jpg", "jpeg" -> "image/jpeg"
"png" -> "image/png"
"gif" -> "image/gif"
"webp" -> "image/webp"
else -> "application/octet-stream"
}
val base64 = android.util.Base64.encodeToString(imageBytes, android.util.Base64.NO_WRAP)
val dataUri = "data:$mimeType;base64,$base64"
imageElement.attr("xlink:href", dataUri)
imageElement.removeAttr("href")
} catch (e: Exception) {
Timber.e(e, "Failed to read and encode image file '$href' to Base64.")
}
}
}
}
return svgElement.outerHtml()
} catch (e: Exception) {
Timber.e(e, "Error while embedding images in SVG content.")
return svgContent
}
}
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(this.chapterAbsPath).parent ?: ""
val fromRelativeFile = File(this.extractionBasePath, File(parentPath, decodedSrc).path)
if (fromRelativeFile.exists()) return fromRelativeFile
val fromRootFile = File(this.extractionBasePath, decodedSrc)
if (fromRootFile.exists()) return fromRootFile
Timber.w("Image not found for SVG embedding. Tried: ${fromRelativeFile.absolutePath} and ${fromRootFile.absolutePath}")
return null
}
private fun applyThemeToSvg(svgContent: String): String {
if (svgContent.isBlank()) return svgContent
try {
val textColorHex = baseTextStyle.color.toCssHexString()
val svgDocument = Jsoup.parseBodyFragment(svgContent)
val svgElement = svgDocument.body().children().firstOrNull() ?: return svgContent
svgElement.select("text").forEach { textElement ->
val existingStyle = textElement.attr("style")
val styleWithoutFill = existingStyle.replace(Regex("""\bfill\s*:\s*[^;]+;?"""), "")
val newStyle = "fill:$textColorHex; $styleWithoutFill".trim()
textElement.attr("style", newStyle)
textElement.removeAttr("fill")
}
return svgElement.outerHtml()
} catch (e: Exception) {
Timber.e(e, "Failed to apply dark theme to SVG content.")
return svgContent
}
}
private fun Color.toCssHexString(): String {
val red = (this.red * 255).toInt()
val green = (this.green * 255).toInt()
val blue = (this.blue * 255).toInt()
return String.format("#%02X%02X%02X", red, green, blue)
}
private fun buildAnnotatedString(
block: SemanticTextBlock,
blockStyle: CssStyle
): AnnotatedString {
Timber.d("ContentStyler: Building annotated string. UserAlign=$userTextAlign, CSSAlign=${blockStyle.paragraphStyle.textAlign}")
val builtString = buildAnnotatedString {
val rootFontFamily = findFirstAvailableFontFamily(blockStyle.fontFamilies, fontFamilyMap)
val hyphensValue = if (blockStyle.hyphens == "auto") Hyphens.Auto else Hyphens.None
val mergedParagraphStyle = baseTextStyle.toParagraphStyle().merge(blockStyle.paragraphStyle)
val finalTextAlign = if (block is SemanticParagraph && userTextAlign != null) {
userTextAlign
} else if (mergedParagraphStyle.textAlign == TextAlign.Justify) {
TextAlign.Left
} else {
mergedParagraphStyle.textAlign
}
val isParagraph = block is SemanticParagraph
val finalLineHeight = if (isParagraph && baseTextStyle.lineHeight.isSpecified) {
baseTextStyle.lineHeight
} else {
mergedParagraphStyle.lineHeight
}
val finalParagraphStyle = ParagraphStyle(
textAlign = finalTextAlign,
textDirection = mergedParagraphStyle.textDirection,
lineHeight = finalLineHeight,
textIndent = mergedParagraphStyle.textIndent,
platformStyle = mergedParagraphStyle.platformStyle,
lineHeightStyle = mergedParagraphStyle.lineHeightStyle,
lineBreak = LineBreak.Paragraph,
hyphens = hyphensValue,
textMotion = mergedParagraphStyle.textMotion
)
var initialSpanStyle = baseTextStyle.toSpanStyle()
.merge(blockStyle.spanStyle)
.copy(fontFamily = baseTextStyle.fontFamily)
if (rootFontFamily == FontFamily.Monospace) {
initialSpanStyle = initialSpanStyle.copy(fontFamily = rootFontFamily)
}
Timber.d("ContentStyler: InitialSpanStyle. BaseFontSize=${baseTextStyle.fontSize}, BlockFontSize=${blockStyle.spanStyle.fontSize} -> Merged=${initialSpanStyle.fontSize}")
withStyle(finalParagraphStyle) {
withStyle(initialSpanStyle) {
append(block.text)
block.spans.sortedBy { it.start }.forEach { span ->
val themedSpanStyle = applyThemeToStyle(span.style)
val fontFamily = findFirstAvailableFontFamily(themedSpanStyle.fontFamilies, fontFamilyMap)
val baselineShift = when (span.tag) {
"sub" -> BaselineShift.Subscript
"sup" -> BaselineShift.Superscript
else -> null
}
val finalSpanStyle = themedSpanStyle.spanStyle.copy(
fontFamily = fontFamily,
baselineShift = baselineShift
)
addStyle(initialSpanStyle.merge(finalSpanStyle), span.start, span.end)
if (span.linkHref != null) {
addStringAnnotation("URL", span.linkHref, span.start, span.end)
}
if (span.elementId != null) {
addStringAnnotation("ID", span.elementId, span.start, span.end)
}
}
}
}
}
return builtString.maybeAdjustLineHeightForEmphasis()
}
private fun AnnotatedString.maybeAdjustLineHeightForEmphasis(): AnnotatedString {
if (this.getStringAnnotations("TextEmphasis", 0, this.length).isNotEmpty()) {
val currentParagraphStyle = this.paragraphStyles.firstOrNull()?.item ?: ParagraphStyle()
val currentLineHeight = currentParagraphStyle.lineHeight
val newLineHeight = if (currentLineHeight.isUnspecified || currentLineHeight.value == 0f) {
1.8.em
} else if (currentLineHeight.isEm) {
(currentLineHeight.value * 1.3f).em
} else if (currentLineHeight.isSp) {
(currentLineHeight.value * 1.3f).sp
} else {
1.8.em
}
return buildAnnotatedString {
withStyle(ParagraphStyle(lineHeight = newLineHeight)) {
append(this@maybeAdjustLineHeightForEmphasis)
}
}
}
return this
}
private fun styleList(list: SemanticList, listStyle: CssStyle): ContentBlock {
var itemCounter = 1
val items = list.items.map { item ->
val itemThemedStyle = applyThemeToStyle(item.style)
val mergedBlockStyle = listStyle.blockStyle.merge(itemThemedStyle.blockStyle)
val marker = getListMarker(
listStyleType = mergedBlockStyle.listStyleType,
counter = itemCounter,
isOrdered = list.isOrdered
)
itemCounter++
ListItemBlock(
content = buildAnnotatedString(item, itemThemedStyle),
itemMarker = marker,
itemMarkerImage = item.itemMarkerImage,
style = mergedBlockStyle,
elementId = item.elementId,
cfi = item.cfi,
startCharOffsetInSource = item.startCharOffsetInSource,
blockIndex = item.blockIndex
)
}
return FlexContainerBlock(items, listStyle.blockStyle, list.elementId, list.cfi, list.blockIndex)
}
private fun styleTable(table: SemanticTable, tableStyle: CssStyle): TableBlock {
val rows = table.rows.map { row ->
row.map { cell ->
val cellCssStyle = applyThemeToStyle(cell.style)
TableCell(
content = cell.content.mapNotNull { styleBlock(it) },
isHeader = cell.isHeader,
style = cellCssStyle,
colspan = cell.colspan
)
}
}
return TableBlock(
rows = rows,
style = tableStyle.blockStyle,
elementId = table.elementId,
cfi = table.cfi,
blockIndex = table.blockIndex
)
}
private fun findFirstAvailableFontFamily(
fontFamilyNames: List<String>,
fontFamilyMap: Map<String, FontFamily>
): FontFamily? {
if (fontFamilyNames.isEmpty()) return null
val specificFont = fontFamilyNames.firstNotNullOfOrNull { fontFamilyMap[it] }
if (specificFont != null) return specificFont
return fontFamilyNames.firstNotNullOfOrNull { name -> FontFamilyMapper.nameToFontFamily(name) }
}
private fun toRoman(number: Int): String {
if (number < 1 || number > 3999) return number.toString()
val values = listOf(1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1)
val symbols = listOf("M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I")
val result = StringBuilder()
var num = number
for (i in values.indices) {
while (num >= values[i]) {
num -= values[i]
result.append(symbols[i])
}
}
return result.toString()
}
private fun toAlpha(number: Int): String {
if (number < 1) return number.toString()
var n = number
val result = StringBuilder()
while (n > 0) {
n--
result.insert(0, ('a' + n % 26))
n /= 26
}
return result.toString()
}
private fun getListMarker(listStyleType: String?, counter: Int, isOrdered: Boolean): String? {
val finalType = listStyleType?.trim()?.lowercase() ?: if (isOrdered) "decimal" else "disc"
return when (finalType) {
"none" -> null
"disc" -> ""
"circle" -> ""
"square" -> ""
"decimal" -> "$counter. "
"decimal-leading-zero" -> "${counter.toString().padStart(2, '0')}. "
"lower-roman" -> toRoman(counter).lowercase() + ". "
"upper-roman" -> toRoman(counter).uppercase() + ". "
"lower-latin", "lower-alpha" -> toAlpha(counter) + ". "
"upper-latin", "upper-alpha" -> toAlpha(counter).uppercase() + ". "
else -> if (isOrdered) "$counter. " else ""
}
}
}

View file

@ -0,0 +1,859 @@
// CssParser.kt
package com.aryan.reader.paginatedreader
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.graphics.toArgb
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.text.style.TextDecoration
import androidx.compose.ui.text.style.TextIndent
import androidx.compose.ui.unit.Constraints
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
import java.io.File
import java.util.regex.Pattern
import kotlin.math.roundToInt
private const val IMPORTANT_SPECIFICITY_BOOST = 10_000
private fun Color.luminance(): Float {
if (!this.isSpecified) return 0f
return (0.299f * red + 0.587f * green + 0.114f * blue)
}
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
object CssParser {
private val FONT_FACE_REGEX = "@font-face\\s*\\{([^}]+)\\}".toRegex(RegexOption.DOT_MATCHES_ALL)
private val URL_REGEX = "url\\((['\"]?)(.*?)\\1\\)".toRegex()
private val ID_SELECTOR_PATTERN = Pattern.compile("#[^\\s,]+")
private val CLASS_ATTRIBUTE_SELECTOR_PATTERN = Pattern.compile("\\.[^\\s,]+|\\[[^]]+]|:(?!:)[^\\s,]+")
private val TYPE_PSEUDO_ELEMENT_SELECTOR_PATTERN = Pattern.compile("(?<![.#\\[])\\b[a-zA-Z-]+|::[a-zA-Z-]+")
private data class FontSource(val url: String, val format: String?)
// Regex to identify simple, single-part selectors for fast categorization
private val SIMPLE_TAG_SELECTOR = Regex("^[a-zA-Z0-9]+$")
private val SIMPLE_CLASS_SELECTOR = Regex("^\\.[a-zA-Z0-9_-]+$")
private val SIMPLE_ID_SELECTOR = Regex("^#[a-zA-Z0-9_-]+$")
private val BORDER_WIDTH_KEYWORDS = mapOf(
"thin" to 1.dp,
"medium" to 3.dp,
"thick" to 5.dp
)
internal fun adaptColorForTheme(color: Color, isDarkTheme: Boolean, isBackground: Boolean): Color {
if (!color.isSpecified) return color
if (color.alpha < 0.9f) return color
val luminance = color.luminance()
return if (isDarkTheme) {
if (isBackground) {
if (luminance > 0.9) Color.Transparent else color
} else {
if (luminance < 0.2) Color.White.copy(alpha = 0.87f) else color
}
} else {
if (isBackground) {
if (luminance < 0.1) Color.Transparent else color
} else {
if (luminance > 0.8) Color.Black.copy(alpha = 0.87f) else color
}
}
}
private fun splitDeclarations(declarations: String): List<String> {
val parts = declarations.split(';').toMutableList()
if (parts.size <= 1) return parts
val result = mutableListOf<String>()
val iterator = parts.listIterator()
while(iterator.hasNext()) {
var current = iterator.next()
val originalCurrent = current
var reassembled = false
while (current.count { it == '(' } > current.count { it == ')' }) {
if (!iterator.hasNext()) break
val nextPart = iterator.next()
current += ";$nextPart"
reassembled = true
}
if (reassembled) {
Timber.d("Reassembled declaration. Original: '$originalCurrent'. Final: '$current'")
}
result.add(current)
}
return result
}
private fun calculateSpecificity(selector: String): Int {
val ids = ID_SELECTOR_PATTERN.matcher(selector).run {
var count = 0
while (find()) count++
count
}
val classesAndAttributes = CLASS_ATTRIBUTE_SELECTOR_PATTERN.matcher(selector).run {
var count = 0
while (find()) count++
count
}
val elementsAndPseudos = TYPE_PSEUDO_ELEMENT_SELECTOR_PATTERN.matcher(selector).run {
var count = 0
while (find()) count++
count
}
val specificity = ids * 100 + classesAndAttributes * 10 + elementsAndPseudos
return specificity
}
fun parse(
cssContent: String,
cssPath: String?,
baseFontSizeSp: Float,
density: Float,
constraints: Constraints,
isDarkTheme: Boolean
): OptimizedCssParseResult {
val byTag = mutableMapOf<String, MutableList<CssRule>>()
val byClass = mutableMapOf<String, MutableList<CssRule>>()
val byId = mutableMapOf<String, MutableList<CssRule>>()
val otherComplex = mutableListOf<CssRule>()
val fontFaces = mutableListOf<FontFaceInfo>()
val blockRegex = "([^{}]+)\\s*\\{([^}]+)\\}".toRegex()
var cleanedCss = cssContent.replace(Regex("/\\*.*?\\*/", RegexOption.DOT_MATCHES_ALL), "")
val mediaQueryRegex = Regex("@media[^{]+\\{((?>[^{}]+|\\{[^{}]*\\})*)\\}")
mediaQueryRegex.findAll(cleanedCss).forEach { match ->
val condition = match.groups[0]?.value?.trim() ?: ""
if (isDarkTheme && condition.contains("prefers-color-scheme: dark")) {
val darkCss = match.groups[1]?.value ?: ""
cleanedCss += "\n$darkCss"
}
}
cleanedCss = mediaQueryRegex.replace(cleanedCss, "")
Timber.d("CssParser: Checking for @font-face rules...")
val fontFaceMatches = FONT_FACE_REGEX.findAll(cleanedCss)
if (!fontFaceMatches.any()) {
Timber.d("CssParser: No @font-face rules found by regex.")
}
fontFaceMatches.forEach { match ->
Timber.d("CssParser: Found a @font-face block. Parsing its properties.")
val properties = match.groupValues[1]
parseFontFace(properties, cssPath)?.let { fontFaces.add(it) }
}
cleanedCss = FONT_FACE_REGEX.replace(cleanedCss, "")
blockRegex.findAll(cleanedCss).forEach { matchResult ->
val selectorGroup = matchResult.groups[1]?.value?.trim() ?: ""
val propertiesGroup = matchResult.groups[2]?.value?.trim() ?: ""
val selectors = selectorGroup.split(',').map { it.trim() }
for (originalSelector in selectors) {
if (originalSelector.isBlank() || originalSelector.startsWith("@")) {
continue
}
val sanitizedSelector = originalSelector.replace(
Regex(":(link|visited|hover|active|focus)\\b|::(first-letter|first-line|marker)\\b", RegexOption.IGNORE_CASE),
""
).trim()
if (sanitizedSelector.isBlank()) {
continue
}
val specificity = calculateSpecificity(originalSelector)
val normalStyle = parseProperties(
propertiesGroup, baseFontSizeSp, density, constraints, onlyImportant = false,
isDarkTheme
)
val importantStyle = parseProperties(
propertiesGroup, baseFontSizeSp, density, constraints, onlyImportant = true,
isDarkTheme
)
fun addRule(style: CssStyle, spec: Int) {
if (style == CssStyle()) return
val rule = CssRule(CssSelector(sanitizedSelector, spec), style)
when {
SIMPLE_ID_SELECTOR.matches(sanitizedSelector) ->
byId.getOrPut(sanitizedSelector.substring(1)) { mutableListOf() }.add(rule)
SIMPLE_CLASS_SELECTOR.matches(sanitizedSelector) ->
byClass.getOrPut(sanitizedSelector.substring(1)) { mutableListOf() }.add(rule)
SIMPLE_TAG_SELECTOR.matches(sanitizedSelector) ->
byTag.getOrPut(sanitizedSelector) { mutableListOf() }.add(rule)
else -> otherComplex.add(rule)
}
}
addRule(normalStyle, specificity)
addRule(importantStyle, specificity + IMPORTANT_SPECIFICITY_BOOST)
}
}
val optimizedRules = OptimizedCssRules(byTag, byClass, byId, otherComplex)
return OptimizedCssParseResult(optimizedRules, fontFaces)
}
private fun parseFontFace(properties: String, cssPath: String?): FontFaceInfo? {
val propsMap = splitDeclarations(properties)
.map { it.trim().split(':', limit = 2).map { part -> part.trim() } }
.filter { it.size == 2 && it[0].isNotBlank() }
.associate { it[0].lowercase() to it[1] }
val fontFamily = propsMap["font-family"]?.removeSurrounding("\"")?.removeSurrounding("'")?.lowercase()
val srcString = propsMap["src"]
Timber.d("Parsing font-face for family: $fontFamily. Raw src string: $srcString")
if (fontFamily == null || srcString == null) {
Timber.w("Incomplete @font-face rule: missing font-family or src.")
return null
}
val urlWithFormatRegex = "url\\((['\"]?)(.*?)\\1\\)\\s*format\\((['\"]?)(.*?)\\3\\)".toRegex()
val sources = srcString.split(Regex(",(?=\\s*url\\()")).mapNotNull { part ->
val trimmedPart = part.trim()
Timber.d("Processing src part: '$trimmedPart'")
urlWithFormatRegex.find(trimmedPart)?.let {
Timber.d("Matched url with format(). URL: ${it.groupValues[2]}, Format: ${it.groupValues[4]}")
FontSource(url = it.groupValues[2], format = it.groupValues[4].lowercase().removeSurrounding("'"))
} ?: URL_REGEX.find(trimmedPart)?.let {
val url = it.groupValues[2]
Timber.d("Matched url() only. URL: '$url'")
val format = when {
url.startsWith("data:", ignoreCase = true) -> {
val mediaType = url.substringAfter("data:").substringBefore(';')
Timber.d("Data URI detected. Media type: '$mediaType'")
when {
mediaType.contains("opentype") -> "opentype"
mediaType.contains("truetype") -> "truetype"
mediaType.contains("woff2") -> "woff2"
mediaType.contains("woff") -> "woff"
else -> {
Timber.w("Unknown data URI media type: $mediaType")
null
}
}
}
url.endsWith(".woff2", ignoreCase = true) -> "woff2"
url.endsWith(".woff", ignoreCase = true) -> "woff"
url.endsWith(".otf", ignoreCase = true) -> "opentype"
url.endsWith(".ttf", ignoreCase = true) -> "truetype"
else -> {
Timber.w("Could not determine format from URL: $url")
null
}
}
Timber.d("Determined format: '$format'")
if (format != null) {
FontSource(url = url, format = format)
} else {
null
}
}
}
if (sources.isEmpty()) {
Timber.w("Could not parse any valid source from @font-face src: $srcString")
return null
}
val preferredSource = sources.minByOrNull {
when (it.format) {
"opentype", "otf" -> 1
"truetype", "ttf" -> 2
"woff2" -> 3
"woff" -> 4
else -> 5
}
}!!
val rawSrc = preferredSource.url
Timber.d("Selected font source for '$fontFamily': '${preferredSource.url}' with format '${preferredSource.format}'")
val finalSrc = if (cssPath != null && !rawSrc.startsWith("data:")) {
try {
val cssParentDir = File(cssPath).parent ?: ""
File(cssParentDir, rawSrc).normalize().path
} catch (e: Exception) {
Timber.e(e, "Could not resolve font path for src '$rawSrc' in css '$cssPath'")
rawSrc // Fallback to the raw path on error
}
} else {
rawSrc
}
val fontWeight = when (propsMap["font-weight"]) {
"bold" -> FontWeight.Bold
"700" -> FontWeight.Bold
"600" -> FontWeight.SemiBold
"500" -> FontWeight.Medium
"300" -> FontWeight.Light
"200" -> FontWeight.ExtraLight
"100" -> FontWeight.Thin
else -> FontWeight.Normal
}
val fontStyle = when (propsMap["font-style"]) {
"italic", "oblique" -> FontStyle.Italic
else -> FontStyle.Normal
}
return FontFaceInfo(fontFamily, finalSrc, fontWeight, fontStyle)
}
internal fun parseProperties(
properties: String,
baseFontSizeSp: Float,
density: Float,
constraints: Constraints,
onlyImportant: Boolean,
isDarkTheme: Boolean
): CssStyle {
var spanStyle = SpanStyle()
var paragraphStyle = ParagraphStyle()
var padding = BoxBorders()
var width: Dp = Dp.Unspecified
var maxWidth: Dp = Dp.Unspecified
var height: Dp = Dp.Unspecified
var backgroundColor: Color = Color.Unspecified
// Changed: Track the max width found to prioritize visible borders
var maxBorderWidthFound: Dp = 0.dp
var finalBorderColor: Color? = null
var finalBorderStyle: String? = null
var fontFamilies: List<String> = emptyList()
var fontSize: TextUnit = TextUnit.Unspecified
var pageBreakInsideAvoid = false
var listStyleType: String? = null
var listStyleImage: String? = null
var display: String? = null
val containerWidthPx = constraints.maxWidth
var pageBreakAfterAvoid = false
var textTransform: String? = null
var boxSizing: String? = null
var float: String? = null
var clear: String? = null
var content: String? = null
var position: String? = null
var left: Dp = Dp.Unspecified
var top: Dp = Dp.Unspecified
var right: Dp = Dp.Unspecified
var bottom: Dp = Dp.Unspecified
var flexDirection: String? = null
var justifyContent: String? = null
var alignItems: String? = null
var filter: String? = null
var borderCollapse: String? = null
var borderSpacing: Dp = 0.dp
var borderRadius: Dp = 0.dp
var hyphens: String? = null
var fontVariantNumeric: String? = null
var textEmphasisStyleString: String? = null
var textEmphasisColor: Color? = null
var textEmphasisPositionString: String? = null
var marginTopStr: String? = null
var marginRightStr: String? = null
var marginBottomStr: String? = null
var marginLeftStr: String? = null
splitDeclarations(properties).filter { it.isNotBlank() }.forEach { prop ->
val parts = prop.split(':', limit = 2).map { it.trim() }
if (parts.size == 2) {
val key = parts[0].lowercase()
val valueWithImportant = parts[1]
val isImportant = valueWithImportant.contains("!important", ignoreCase = true)
if (isImportant != onlyImportant) {
return@forEach
}
val value = if (isImportant) {
valueWithImportant.replace(Regex("\\s*!important", RegexOption.IGNORE_CASE), "").trim()
} else {
valueWithImportant
}
// Helper to update border props ONLY if this border is significant
fun updateUnifiedBorder(
widthStr: String?,
colorStr: String?,
styleStr: String?
) {
val parsedWidth = widthStr?.let { parseCssSizeToDp(it, baseFontSizeSp, density, containerWidthPx) } ?: 0.dp
val parsedColor = colorStr?.let { parseColor(it) }?.let { this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false) }
// We update the unified style if:
// 1. We found a width larger than what we've seen (prioritize visible borders)
// 2. Or we haven't seen any width yet and this is the first definition
// 3. Or the specific property is just setting style/color and we want to take the last one defined (standard CSS cascade behavior for same-specificity)
// However, for separate sides (left vs bottom), we strictly prioritize the one with width.
val isExplicitWidth = widthStr != null
if (parsedWidth > maxBorderWidthFound) {
maxBorderWidthFound = parsedWidth
if (parsedColor != null) finalBorderColor = parsedColor
if (styleStr != null) finalBorderStyle = styleStr
} else if (parsedWidth == maxBorderWidthFound && maxBorderWidthFound > 0.dp) {
// If equal non-zero width, let last defined win (cascade)
if (parsedColor != null) finalBorderColor = parsedColor
if (styleStr != null) finalBorderStyle = styleStr
} else if (!isExplicitWidth) {
// Just updating color or style without width
if (parsedColor != null) finalBorderColor = parsedColor
if (styleStr != null) finalBorderStyle = styleStr
}
}
when (key) {
// ... [Keep existing cases for font-family, font-size, font-weight, font-style, color, text-align, line-height, text-indent, text-decoration, letter-spacing, text-transform, font-variant, margin, margin-*, padding, padding-*, width, max-width, height, background-color] ...
"font-family" -> {
fontFamilies = value.split(',')
.map { it.trim().removeSurrounding("\"").removeSurrounding("'").lowercase() }
}
"font-size" -> {
val trimmedValue = value.trim().lowercase()
fontSize = if (trimmedValue.endsWith("%")) {
val percentage = trimmedValue.removeSuffix("%").toFloatOrNull()
if (percentage != null) {
(percentage / 100f).em
} else {
TextUnit.Unspecified
}
} else {
parseCssDimensionToTextUnit(value, containerWidthPx, density)
}
}
"font-weight" -> {
spanStyle = spanStyle.copy(fontWeight = when (value) {
"bold" -> FontWeight.Bold
"700" -> FontWeight.Bold
"600" -> FontWeight.SemiBold
"500" -> FontWeight.Medium
"300" -> FontWeight.Light
"200" -> FontWeight.ExtraLight
"100" -> FontWeight.Thin
"normal" -> FontWeight.Normal
"400" -> FontWeight.Normal
else -> value.toIntOrNull()?.let { FontWeight(it) } ?: spanStyle.fontWeight
})
}
"font-style" -> {
if (value == "italic" || value == "oblique") spanStyle = spanStyle.copy(fontStyle = FontStyle.Italic)
else if (value == "normal") spanStyle = spanStyle.copy(fontStyle = FontStyle.Normal)
}
"color" -> {
parseColor(value)?.let {
spanStyle = spanStyle.copy(color = this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false))
}
}
"text-align" -> {
val align = when (value) {
"center" -> TextAlign.Center
"right" -> TextAlign.End
"justify" -> TextAlign.Justify
else -> TextAlign.Start
}
paragraphStyle = paragraphStyle.copy(textAlign = align)
}
"line-height" -> {
val trimmedValue = value.trim()
var lineHeight = when {
trimmedValue.endsWith("%") -> {
val percentage = trimmedValue.removeSuffix("%").toFloatOrNull()
if (percentage != null) {
(percentage / 100f).em
} else {
TextUnit.Unspecified
}
}
trimmedValue.toFloatOrNull() != null && trimmedValue.none { it.isLetter() } -> {
trimmedValue.toFloatOrNull()?.em ?: TextUnit.Unspecified
}
else -> parseCssDimensionToTextUnit(trimmedValue, containerWidthPx, density)
}
if (lineHeight.isEm && lineHeight.value < 1.2f && lineHeight.value > 0) {
lineHeight = 2f.em
}
if (lineHeight != TextUnit.Unspecified) {
paragraphStyle = paragraphStyle.copy(lineHeight = lineHeight)
}
}
"text-indent" -> {
val indent = parseCssDimensionToTextUnit(value, containerWidthPx, density)
if (indent != TextUnit.Unspecified) {
paragraphStyle = paragraphStyle.copy(textIndent = TextIndent(firstLine = indent))
}
}
"text-decoration" -> {
spanStyle = spanStyle.copy(
textDecoration = when(value) {
"underline" -> TextDecoration.Underline
"line-through" -> TextDecoration.LineThrough
"none" -> TextDecoration.None
else -> spanStyle.textDecoration
}
)
}
"letter-spacing" -> {
val letterSpacing = parseCssDimensionToTextUnit(value, containerWidthPx, density)
if (letterSpacing != TextUnit.Unspecified) {
spanStyle = spanStyle.copy(letterSpacing = letterSpacing)
}
}
"text-transform" -> {
textTransform = when (value) {
"uppercase", "lowercase", "capitalize", "none" -> value
else -> null
}
}
"font-variant" -> {
if (value.contains("small-caps")) {
spanStyle = spanStyle.copy(fontFeatureSettings = "\"smcp\" on")
}
}
"margin" -> {
val marginParts = value.split(' ').filter { it.isNotBlank() }
when (marginParts.size) {
1 -> {
marginTopStr = marginParts[0]; marginRightStr = marginParts[0]; marginBottomStr = marginParts[0]; marginLeftStr = marginParts[0]
}
2 -> {
marginTopStr = marginParts[0]; marginBottomStr = marginParts[0]
marginRightStr = marginParts[1]; marginLeftStr = marginParts[1]
}
3 -> {
marginTopStr = marginParts[0]
marginRightStr = marginParts[1]; marginLeftStr = marginParts[1]
marginBottomStr = marginParts[2]
}
4 -> {
marginTopStr = marginParts[0]; marginRightStr = marginParts[1]; marginBottomStr = marginParts[2]; marginLeftStr = marginParts[3]
}
}
}
"margin-top" -> marginTopStr = value
"margin-bottom" -> marginBottomStr = value
"margin-left" -> marginLeftStr = value
"margin-right" -> marginRightStr = value
"padding" -> padding = parseBoxBorders(value, baseFontSizeSp, density, containerWidthPx)
"padding-top" -> padding = padding.copy(top = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx))
"padding-bottom" -> padding = padding.copy(bottom = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx))
"padding-left" -> padding = padding.copy(left = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx))
"padding-right" -> padding = padding.copy(right = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx))
"width" -> width = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
"max-width" -> maxWidth = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
"height" -> height = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
"background-color" -> {
val originalColor = parseColor(value) ?: Color.Unspecified
backgroundColor = this@CssParser.adaptColorForTheme(originalColor, isDarkTheme, isBackground = true)
}
// Border Properties - Logic Updated
"border-width" -> updateUnifiedBorder(value, null, null)
"border-color" -> updateUnifiedBorder(null, value, null)
"border-style" -> updateUnifiedBorder(null, null, value)
"border-top-width", "border-bottom-width", "border-left-width", "border-right-width" -> {
updateUnifiedBorder(value, null, null)
}
"border-top-color", "border-bottom-color", "border-left-color", "border-right-color" -> {
updateUnifiedBorder(null, value, null)
}
"border-top-style", "border-bottom-style", "border-left-style", "border-right-style" -> {
updateUnifiedBorder(null, null, value)
}
"border-bottom", "border-top", "border-left", "border-right", "border" -> {
val borderParts = value.split(" ").filter { it.isNotBlank() }
var widthVal: String? = null
var colorVal: String? = null
var styleVal: String? = null
borderParts.forEach { part ->
val parsedWidth = parseCssSizeToDp(part, baseFontSizeSp, density, containerWidthPx)
if (parsedWidth > 0.dp || part == "0" || part == "0px" || BORDER_WIDTH_KEYWORDS.containsKey(part)) {
widthVal = part
} else if (part in listOf("solid", "dotted", "dashed", "double", "groove", "ridge", "inset", "outset")) {
styleVal = part
} else if (parseColor(part) != null) {
colorVal = part
}
}
updateUnifiedBorder(widthVal, colorVal, styleVal)
}
// End Border Properties
"border-collapse" -> {
if (value in listOf("collapse", "separate")) {
borderCollapse = value
}
}
"border-spacing" -> {
borderSpacing = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
}
"border-radius" -> borderRadius = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
// ... [Keep existing cases for list-style-*, page-break-*, display, flex-*, filter, box-sizing, content, position, top/left/etc, float, hyphens, etc] ...
"list-style-type" -> {
listStyleType = value
}
"list-style-image" -> {
URL_REGEX.find(value)?.groupValues?.get(2)?.let {
listStyleImage = it
}
}
"page-break-inside" -> {
if (value == "avoid") {
pageBreakInsideAvoid = true
}
}
"page-break-after" -> {
if (value == "avoid") {
pageBreakAfterAvoid = true
}
}
"display" -> display = value
"flex-direction" -> flexDirection = value
"justify-content" -> justifyContent = value
"align-items" -> alignItems = value
"filter" -> filter = value
"box-sizing" -> boxSizing = value
"content" -> content = value.removeSurrounding("\"").removeSurrounding("'")
"position" -> position = value
"left" -> left = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
"right" -> right = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
"top" -> top = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
"bottom" -> bottom = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
"float" -> {
if (value in listOf("left", "right", "none")) {
float = value
}
}
"hyphens", "-webkit-hyphens", "-moz-hyphens", "-epub-hyphens", "adobe-hyphenate" -> {
if (value in listOf("auto", "manual", "none")) {
hyphens = value
}
}
"font-variant-numeric" -> {
fontVariantNumeric = value
}
"clear" -> {
if (value in listOf("left", "right", "both", "none")) {
clear = value
}
}
"text-emphasis", "-epub-text-emphasis" -> {
textEmphasisStyleString = value
}
"text-emphasis-style", "-epub-text-emphasis-style" -> {
textEmphasisStyleString = value
}
"text-emphasis-color", "-epub-text-emphasis-color" -> {
textEmphasisColor = parseColor(value)?.let { this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false) }
}
"text-emphasis-position", "-epub-text-emphasis-position" -> {
if (value in listOf("over", "under")) {
textEmphasisPositionString = value
}
}
}
}
}
val finalHorizontalAlign = if (marginLeftStr == "auto" && marginRightStr == "auto") "center" else null
val margin = BoxBorders(
top = marginTopStr?.let { parseCssSizeToDp(it, baseFontSizeSp, density, containerWidthPx) } ?: 0.dp,
bottom = marginBottomStr?.let { parseCssSizeToDp(it, baseFontSizeSp, density, containerWidthPx) } ?: 0.dp,
left = marginLeftStr.takeIf { it != "auto" }?.let { parseCssSizeToDp(it, baseFontSizeSp, density, containerWidthPx) } ?: 0.dp,
right = marginRightStr.takeIf { it != "auto" }?.let { parseCssSizeToDp(it, baseFontSizeSp, density, containerWidthPx) } ?: 0.dp
)
// ... [TextEmphasis object creation code remains same] ...
val textEmphasis = if (textEmphasisStyleString != null) {
val parts = textEmphasisStyleString.split(' ').filter { it.isNotBlank() }
var fill: String? = null
var style: String? = null
parts.forEach { part ->
when (part) {
"filled", "open" -> fill = part
"dot", "circle", "double-circle", "triangle", "sesame" -> style = part
else -> {
style = part.removeSurrounding("'").removeSurrounding("\"")
}
}
}
TextEmphasis(
style = style,
fill = fill,
color = textEmphasisColor ?: Color.Unspecified,
position = textEmphasisPositionString
)
} else {
null
}
// Updated: Use the maxBorderWidthFound and corresponding colors
val finalBorder = if (maxBorderWidthFound > 0.dp && finalBorderStyle != null) {
val borderColor = finalBorderColor ?: spanStyle.color.takeIf { it.isSpecified } ?: Color.Black
BorderStyle(
width = maxBorderWidthFound,
color = borderColor,
style = finalBorderStyle
)
} else null
val blockStyle = BlockStyle(
margin = margin, padding = padding, width = width, maxWidth = maxWidth, height = height,
backgroundColor = backgroundColor, border = finalBorder,
listStyleType = listStyleType,
listStyleImage = listStyleImage,
pageBreakInsideAvoid = pageBreakInsideAvoid,
pageBreakAfterAvoid = pageBreakAfterAvoid,
boxSizing = boxSizing,
float = float,
clear = clear,
position = position,
left = left,
right = right,
top = top,
bottom = bottom,
display = display,
flexDirection = flexDirection,
justifyContent = justifyContent,
alignItems = alignItems,
horizontalAlign = finalHorizontalAlign,
filter = filter,
borderCollapse = borderCollapse,
borderSpacing = borderSpacing,
borderRadius = borderRadius
)
return CssStyle(spanStyle, paragraphStyle, blockStyle, fontFamilies, display, fontSize, textTransform, boxSizing, content, hyphens, fontVariantNumeric, textEmphasis)
}
// ADD the parseCssSizeToDp function here at the bottom of the object or file
internal fun parseCssSizeToDp(
size: String,
baseFontSizeSp: Float,
density: Float,
containerWidthPx: Int
): Dp {
val trimmed = size.trim().lowercase()
// Handle keywords
BORDER_WIDTH_KEYWORDS[trimmed]?.let { return it }
if (trimmed == "0" || trimmed == "0px") return 0.dp
return when {
trimmed.endsWith("px") -> trimmed.removeSuffix("px").toFloatOrNull()?.let { (it / density).dp } ?: 0.dp
trimmed.endsWith("dp") -> trimmed.removeSuffix("dp").toFloatOrNull()?.dp ?: 0.dp
trimmed.endsWith("em") -> trimmed.removeSuffix("em").toFloatOrNull()?.let { (it * baseFontSizeSp).dp } ?: 0.dp
trimmed.endsWith("rem") -> trimmed.removeSuffix("rem").toFloatOrNull()?.let { (it * baseFontSizeSp).dp } ?: 0.dp
trimmed.endsWith("pt") -> trimmed.removeSuffix("pt").toFloatOrNull()?.let { (it * 1.33f).dp } ?: 0.dp // 1pt ≈ 1.33px
trimmed.endsWith("%") -> {
val percent = trimmed.removeSuffix("%").toFloatOrNull()
if (percent != null) {
((percent / 100f) * containerWidthPx / density).dp
} else {
0.dp
}
}
trimmed.toFloatOrNull() != null -> (trimmed.toFloat() / density).dp
else -> 0.dp
}
}
internal fun parseCssDimensionToTextUnit(
dimension: String?,
containerWidthPx: Int,
density: Float
): TextUnit {
if (dimension.isNullOrBlank()) return TextUnit.Unspecified
val trimmed = dimension.trim().lowercase()
return when {
trimmed.endsWith("px") -> trimmed.removeSuffix("px").toFloatOrNull()?.sp ?: TextUnit.Unspecified
trimmed.endsWith("em") -> trimmed.removeSuffix("em").toFloatOrNull()?.em ?: TextUnit.Unspecified
trimmed.endsWith("rem") -> trimmed.removeSuffix("rem").toFloatOrNull()?.em ?: TextUnit.Unspecified
trimmed.endsWith("%") -> trimmed.removeSuffix("%").toFloatOrNull()?.let { (it / 100f).em } ?: TextUnit.Unspecified
trimmed.endsWith("pt") -> trimmed.removeSuffix("pt").toFloatOrNull()?.let { (it * 1.33f).sp } ?: TextUnit.Unspecified
else -> TextUnit.Unspecified
}
}
internal fun parseColor(colorString: String): Color? {
val sanitized = colorString.trim().lowercase()
return when {
sanitized.startsWith("#") -> {
val hex = sanitized.substring(1)
val colorLong = hex.toLongOrNull(16) ?: return null
when (hex.length) {
3 -> { // #RGB
val r = (colorLong and 0xF00) shr 8
val g = (colorLong and 0x0F0) shr 4
val b = colorLong and 0x00F
Color(Color(0xFF000000 or ((r * 17) shl 16) or ((g * 17) shl 8) or (b * 17)).toArgb())
}
6 -> Color(Color(0xFF000000 or colorLong).toArgb()) // #RRGGBB
8 -> Color(Color(colorLong).toArgb()) // #AARRGGBB
else -> null
}
}
sanitized.startsWith("rgb") -> {
val isRgba = sanitized.startsWith("rgba")
val valuesString = sanitized.substringAfter('(').substringBefore(')')
val values = valuesString.split(',').map { it.trim() }
if (values.size < 3) return null
val r = values[0].toIntOrNull() ?: 0
val g = values[1].toIntOrNull() ?: 0
val b = values[2].toIntOrNull() ?: 0
val a = if (isRgba && values.size == 4) (values[3].toFloatOrNull() ?: 1f) else 1f
Color(r, g, b, (a * 255).roundToInt())
}
else -> when(sanitized) {
"black" -> Color.Black
"white" -> Color.White
"red" -> Color.Red
"green" -> Color.Green
"blue" -> Color.Blue
"gray", "grey" -> Color.Gray
"cyan" -> Color.Cyan
"magenta" -> Color.Magenta
"yellow" -> Color.Yellow
"transparent" -> Color.Transparent
else -> null
}
}
}
private fun parseBoxBorders(value: String, baseFontSizeSp: Float, density: Float, containerWidthPx: Int): BoxBorders {
val parts = value.split(' ').map { it.trim() }.filter { it.isNotEmpty() }
val dps = parts.map { parseCssSizeToDp(it, baseFontSizeSp, density, containerWidthPx) }
return when (dps.size) {
1 -> BoxBorders(top = dps[0], right = dps[0], bottom = dps[0], left = dps[0])
2 -> BoxBorders(top = dps[0], bottom = dps[0], right = dps[1], left = dps[1])
3 -> BoxBorders(top = dps[0], right = dps[1], left = dps[1], bottom = dps[2])
4 -> BoxBorders(top = dps[0], right = dps[1], bottom = dps[2], left = dps[3])
else -> BoxBorders()
}
}
}

View file

@ -0,0 +1,143 @@
// FontLoader.kt
package com.aryan.reader.paginatedreader
import timber.log.Timber
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
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())
return digest.joinToString("") { "%02x".format(it) } + ".ttf"
}
/**
* Loads custom font faces defined in the EPUB's CSS into a map of [FontFamily] objects.
* It handles WOFF2 fonts by converting them to TTF and storing them in a global, persistent cache.
*/
fun loadFontFamilies(fontFaces: List<FontFaceInfo>, extractionPath: String): Map<String, FontFamily> {
if (fontFaces.isEmpty()) {
return emptyMap()
}
Timber.d("Loading ${fontFaces.size} font faces from extraction path: $extractionPath")
// 1. Define a stable, global font cache directory.
// This assumes the parent of the extraction path is a stable base directory for epubs.
val baseCacheDir = File(extractionPath).parentFile ?: return emptyMap()
val fontCacheDir = File(baseCacheDir, "font_cache")
if (!fontCacheDir.exists()) {
fontCacheDir.mkdirs()
}
// 2. Get a stable identifier for the book from the extraction path.
// e.g., "d0e205bf-65cc-4ab4-93cc-cd2d613a7bb3.epub" from a longer temp path.
val bookId = File(extractionPath).name.substringBeforeLast("_")
val fontsByFamily = fontFaces.groupBy {
it.fontFamily.trim().removeSurrounding("'").removeSurrounding("\"").lowercase()
}
Timber.d("Grouped font faces by normalized family: ${fontsByFamily.keys}")
return fontsByFamily.mapValues { (familyName, fontInfos) ->
val fontList = fontInfos.mapNotNull { fontInfo ->
try {
Timber.d("Attempting to load font '$familyName' from resolved src path: '${fontInfo.src}'")
var fontFile = File(extractionPath, fontInfo.src)
if (!fontFile.exists()) {
Timber.w("Font file not found at: ${fontFile.absolutePath}")
return@mapNotNull null
}
// Handle WOFF2 conversion and global caching
if (fontFile.extension.equals("woff2", ignoreCase = true)) {
// 3. Generate a unique, deterministic cache key for the font.
val cacheKey = getCacheKeyForFont(bookId, fontInfo.src)
val cachedTtfFile = File(fontCacheDir, cacheKey)
if (cachedTtfFile.exists()) {
// Use the globally cached TTF file if it exists
fontFile = cachedTtfFile
Timber.d("Using globally cached TTF for '${fontInfo.src}'")
} else {
// Convert and save the TTF to the global cache if it doesn't exist
Timber.d("Converting woff2 font: ${fontFile.name}")
val woff2Data = fontFile.readBytes()
val ttfData = Woff2Converter.convertWoff2ToTtf(woff2Data)
if (ttfData != null) {
cachedTtfFile.writeBytes(ttfData)
fontFile = cachedTtfFile // Use the newly created TTF file
Timber.d("Successfully converted and globally cached woff2 as '${cachedTtfFile.name}'")
} else {
Timber.e("Failed to convert woff2 font: ${fontFile.name}")
return@mapNotNull null
}
}
}
Font(
fontFile,
fontInfo.fontWeight ?: FontWeight.Normal,
fontInfo.fontStyle ?: FontStyle.Normal
)
} catch (e: Exception) {
Timber.e(e, "Error loading font: ${fontInfo.src}")
null
}
}
if (fontList.isNotEmpty()) {
Timber.d("Loaded family '$familyName' with ${fontList.size} font styles.")
FontFamily(fontList)
} else {
Timber.w("Could not load any font styles for family '$familyName'.")
null
}
}.filterValues { it != null }.mapValues { it.value!! }
}

View file

@ -0,0 +1,577 @@
// HtmlParser.kt
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.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.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 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 // Semantic parsing is always theme-agnostic
)
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 body.children().flatMap { childElement ->
parseNodeToSemanticBlocks(childElement, 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.border != null ||
elementStyle.blockStyle.padding != BoxBorders() ||
elementStyle.blockStyle.borderRadius > 0.dp
if (hasBoxStyles) {
val children = element.children().flatMap { child ->
parseNodeToSemanticBlocks(child, elementStyle)
}
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 (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 -> {
if (element.isBlock) {
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()) {
children.add(SemanticParagraph(text, spans, style, element.id().ifBlank { null }, element.getCfiPath(), blockIndex = nextBlockIndex++)) }
textNodesBuffer.clear()
}
element.childNodes().forEach { node ->
if (node is Element) {
val isEffectivelyBlock = node.isBlock || node.tagName().lowercase() in listOf("img", "svg", "math-placeholder", "hr")
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 { Pair(it.outWidth.toFloat(), it.outHeight.toFloat()) }
} catch (_: Exception) {
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++)
}
}

View file

@ -0,0 +1,38 @@
// IPaginator.kt
package com.aryan.reader.paginatedreader
import androidx.compose.runtime.Stable
import com.aryan.reader.SearchResult
import kotlinx.coroutines.flow.Flow
@Stable
interface IPaginator {
val totalPageCount: Int
val isLoading: Boolean
val generation: Int
val pageShiftRequest: Flow<Int>
fun getPageContent(pageIndex: Int): Page?
fun getChapterPathForPage(pageIndex: Int): String?
fun getPlainTextForChapter(chapterIndex: Int): String?
fun navigateToHref(
currentChapterAbsPath: String,
href: String,
onNavigationComplete: (pageIndex: Int) -> Unit
)
fun findPageForSearchResult(
result: SearchResult,
onResult: (pageIndex: Int) -> Unit
)
fun findPageForAnchor(
chapterIndex: Int,
anchor: String?,
onResult: (pageIndex: Int) -> Unit
)
fun findPageForCfi(chapterIndex: Int, cfi: String, onResult: (pageIndex: Int) -> Unit)
fun findPageForCfiAndOffset(chapterIndex: Int, cfi: String, charOffset: Int): Int?
fun findChapterIndexForPage(pageIndex: Int): Int?
fun getCfiForPage(pageIndex: Int): String?
fun onUserScrolledTo(pageIndex: Int)
fun getActiveAnchorForPage(pageIndex: Int, tocAnchors: List<String>): String?
}

View file

@ -0,0 +1,262 @@
// Locator.kt
package com.aryan.reader.paginatedreader
import android.content.Context
import android.os.Build
import timber.log.Timber
import androidx.annotation.RequiresApi
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.paginatedreader.data.BookCacheDao
import com.aryan.reader.paginatedreader.data.ProcessedChapter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.decodeFromByteArray
import kotlinx.serialization.encodeToByteArray
import kotlinx.serialization.protobuf.ProtoBuf
data class Locator(
val chapterIndex: Int,
val blockIndex: Int,
val charOffset: Int
)
/**
* Converts between view-specific locators (like CFI) and the abstract Locator model.
*/
@OptIn(ExperimentalSerializationApi::class)
class LocatorConverter(
private val bookCacheDao: BookCacheDao,
private val proto: ProtoBuf,
private val context: Context
) {
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
private suspend fun processAndCacheChapter(book: EpubBook, chapterIndex: Int): List<SemanticBlock>? = withContext(Dispatchers.IO) {
try {
val chapter = book.chapters.getOrNull(chapterIndex) ?: return@withContext null
// 1. Parse CSS from the book
var parsingCssRules = OptimizedCssRules()
val density = Density(context)
val displayMetrics = context.resources.displayMetrics
val constraints = Constraints(maxWidth = displayMetrics.widthPixels, maxHeight = displayMetrics.heightPixels)
book.css.forEach { (path, content) ->
val bookCssResult = CssParser.parse(
cssContent = content,
cssPath = path,
baseFontSizeSp = 16f, // A reasonable default for non-rendering parsing
density = density.density,
constraints = constraints,
isDarkTheme = false
)
parsingCssRules = parsingCssRules.merge(bookCssResult.rules)
}
// 2. Parse HTML to SemanticBlocks
val semanticBlocks = htmlToSemanticBlocks(
html = chapter.htmlContent,
cssRules = parsingCssRules,
textStyle = TextStyle(), // Not used for rendering, so a default is fine
chapterAbsPath = chapter.absPath,
extractionBasePath = book.extractionBasePath,
density = density,
fontFamilyMap = emptyMap(),
constraints = constraints
)
// 3. Serialize and cache the result
val protoBytes = proto.encodeToByteArray(semanticBlocks)
val newCacheEntry = ProcessedChapter(
bookId = book.title,
chapterIndex = chapterIndex,
contentBlocksProto = protoBytes,
estimatedPageCount = 0 // Page count is not relevant for locator conversion
)
bookCacheDao.insertProcessedChapters(listOf(newCacheEntry))
Timber.i("On-demand processing SUCCESS for chapter $chapterIndex.")
semanticBlocks
} catch (e: Exception) {
Timber.e(e, "On-demand processing FAILED for chapter $chapterIndex")
null
}
}
/**
* Converts a CFI string from the WebView into an abstract Locator.
*/
suspend fun getLocatorFromCfi(book: EpubBook, chapterIndex: Int, cfi: String): Locator? = withContext(Dispatchers.IO) {
Timber.d("getLocatorFromCfi: Starting conversion for book='${book.title}', chapter=$chapterIndex, cfi='$cfi'")
val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = chapterIndex)
val allBlocks = if (processedChapter != null) {
proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto)
} else {
Timber.w("getLocatorFromCfi: Chapter $chapterIndex not in DB. Triggering on-demand processing.")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
processAndCacheChapter(book, chapterIndex)
} else {
Timber.e("On-demand processing requires API 34+, cannot proceed.")
null
}
}
if (allBlocks == null) {
Timber.w("getLocatorFromCfi: FAILED. Could not get or process semantic blocks for chapter $chapterIndex.")
return@withContext null
}
val (baseCfiPath, charOffset) = cfi.split(':').let {
it[0] to (it.getOrNull(1)?.toIntOrNull() ?: 0)
}
Timber.d("getLocatorFromCfi: Parsed CFI into basePath='$baseCfiPath' and charOffset=$charOffset")
val bestMatch = findBestMatchingBlock(allBlocks, baseCfiPath)
if (bestMatch != null) {
Timber.i("getLocatorFromCfi: SUCCESS. Found best match. Block index: ${bestMatch.blockIndex}, Block CFI: '${bestMatch.cfi}'")
Locator(
chapterIndex = chapterIndex,
blockIndex = bestMatch.blockIndex,
charOffset = charOffset
)
} else {
Timber.w("getLocatorFromCfi: FAILED. No matching block found for CFI base path '$baseCfiPath'.")
null
}
}
private fun findBestMatchingBlock(blocks: List<SemanticBlock>, inputCfi: String): SemanticBlock? {
val flattenedBlocks = mutableListOf<SemanticBlock>()
fun flatten(blockList: List<SemanticBlock>) {
for (block in blockList) {
flattenedBlocks.add(block)
when (block) {
is SemanticFlexContainer -> flatten(block.children)
is SemanticTable -> block.rows.forEach { row -> row.forEach { cell -> flatten(cell.content) } }
is SemanticList -> flatten(block.items)
else -> Unit
}
}
}
flatten(blocks)
if (flattenedBlocks.isEmpty()) return null
flattenedBlocks.mapNotNull { it.cfi }
val bestMatch = flattenedBlocks
.filter { it.cfi != null }
.map { block ->
val blockCfi = block.cfi!!
var i = inputCfi.length - 1
var j = blockCfi.length - 1
var length = 0
while (i >= 0 && j >= 0 && inputCfi[i] == blockCfi[j]) {
length++
i--
j--
}
Pair(block, length)
}
.maxByOrNull { it.second }
?.first
return bestMatch
}
suspend fun getCfiFromLocator(bookId: String, locator: Locator): String? = withContext(Dispatchers.IO) {
Timber.d("getCfiFromLocator: Attempting to get CFI from locator: $locator")
val processedChapter = bookCacheDao.getProcessedChapter(bookId = bookId, chapterIndex = locator.chapterIndex)
if (processedChapter == null) {
Timber.w("getCfiFromLocator: FAILED. Could not find processed chapter ${locator.chapterIndex} in database.")
return@withContext null
}
val blocks = proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto)
val foundBlock = findBlockByBlockIndex(blocks, locator.blockIndex)
if (foundBlock != null) {
foundBlock.cfi?.let { cfi ->
val finalCfi = if (locator.charOffset > 0) {
"$cfi:${locator.charOffset}"
} else {
cfi
}
Timber.i("getCfiFromLocator: SUCCESS. Found block ${foundBlock.blockIndex} with CFI '${foundBlock.cfi}'. Final CFI: '$finalCfi'")
finalCfi
}
} else {
Timber.w("getCfiFromLocator: FAILED. Could not find block with index ${locator.blockIndex} in chapter ${locator.chapterIndex}.")
null
}
}
private fun findBlockByBlockIndex(blocks: List<SemanticBlock>, targetBlockIndex: Int): SemanticBlock? {
val queue = ArrayDeque(blocks)
while (queue.isNotEmpty()) {
val block = queue.removeAt(0)
if (block.blockIndex == targetBlockIndex) {
Timber.v("findBlockByBlockIndex: Found match for block index $targetBlockIndex.")
return block
}
// Recurse into nested blocks
when (block) {
is SemanticFlexContainer -> queue.addAll(block.children)
is SemanticTable -> block.rows.forEach { row -> row.forEach { cell -> queue.addAll(cell.content) } }
is SemanticList -> queue.addAll(block.items)
else -> Unit
}
}
Timber.w("findBlockByBlockIndex: No block found for target index $targetBlockIndex.")
return null
}
suspend fun getTextOffset(book: EpubBook, locator: Locator): Int? = withContext(Dispatchers.IO) {
val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = locator.chapterIndex)
val allBlocks = if (processedChapter != null) {
proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto)
} else {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
processAndCacheChapter(book, locator.chapterIndex)
} else null
} ?: return@withContext null
var offset = 0
val separatorLength = 1
fun traverse(blocks: List<SemanticBlock>): Boolean {
for (block in blocks) {
if (block.blockIndex == locator.blockIndex) {
offset += locator.charOffset
return true
}
if (block is SemanticTextBlock) {
offset += block.text.length + separatorLength
}
val children = when (block) {
is SemanticFlexContainer -> block.children
is SemanticTable -> block.rows.flatten().flatMap { it.content }
is SemanticList -> block.items
is SemanticWrappingBlock -> block.paragraphsToWrap
else -> emptyList()
}
if (children.isNotEmpty()) {
if (traverse(children)) return true
}
}
return false
}
if (traverse(allBlocks)) {
return@withContext offset
}
return@withContext null
}
}

View file

@ -0,0 +1,227 @@
// MathMLRenderer.kt
package com.aryan.reader.paginatedreader
import android.annotation.SuppressLint
import android.content.Context
import android.os.Handler
import android.os.Looper
import timber.log.Timber
import android.webkit.ConsoleMessage
import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.resume
sealed class RenderResult {
data class Success(val svg: String) : RenderResult()
data class Failure(val altText: String) : RenderResult()
}
class MathMLRenderer(private val context: Context) {
private var webView: WebView? = null
private val handler = Handler(Looper.getMainLooper())
private var isMathJaxReady = false
private val readySignal = CompletableDeferred<Boolean>()
sealed class Job {
data class Render(
val mathML: String,
val continuation: (RenderResult) -> Unit
) : Job()
}
private val jobQueue = mutableListOf<Job.Render>()
private var isProcessing = false
init {
handler.post {
setupWebView()
}
}
suspend fun awaitReady(): Boolean {
Timber.d("awaitReady: Waiting for WebView and MathJax initialization...")
return withTimeoutOrNull(10_000) {
readySignal.await()
} ?: run {
Timber.e("awaitReady: Timed out waiting for renderer to become ready.")
destroy()
false
}
}
private fun setupWebView() {
try {
WebView.setWebContentsDebuggingEnabled(true)
webView = WebView(context).apply {
@SuppressLint("SetJavaScriptEnabled")
settings.javaScriptEnabled = true
settings.allowFileAccess = true
settings.domStorageEnabled = true
addJavascriptInterface(WebAppInterface { svg ->
completeCurrentJob(RenderResult.Success(svg))
}, "AndroidBridge")
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
Timber.d("WebView page finished loading: $url")
}
}
webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean {
Timber.d("${consoleMessage.message()} -- From line " +
"${consoleMessage.lineNumber()} of ${consoleMessage.sourceId()}"
)
return true
}
}
loadUrl("file:///android_asset/MathML-template.html")
}
} catch (e: Exception) {
Timber.e(e, "Failed to initialize WebView")
webView = null
readySignal.complete(false)
}
}
suspend fun render(mathML: String, originalAltText: String): RenderResult {
if (!awaitReady()) {
Timber.e("WebView is not available or failed to initialize. Failing render.")
return RenderResult.Failure(originalAltText)
}
return suspendCancellableCoroutine { continuation ->
val job = Job.Render(mathML) { result ->
if (continuation.isActive) {
continuation.resume(result)
}
}
// Add job to the queue and start processing if not already
synchronized(jobQueue) {
jobQueue.add(job)
if (!isProcessing) {
processNextJob()
}
}
continuation.invokeOnCancellation {
synchronized(jobQueue) {
jobQueue.remove(job)
}
}
}
}
private fun processNextJob() {
synchronized(jobQueue) {
if (jobQueue.isEmpty()) {
isProcessing = false
return
}
isProcessing = true
}
handler.post {
executeRender()
}
}
private fun executeRender() {
if (!isMathJaxReady) {
Timber.d("executeRender called but MathJax not ready yet. Retrying...")
handler.postDelayed({ executeRender() }, 100)
return
}
val job = synchronized(jobQueue) { jobQueue.firstOrNull() }
if (job == null) {
isProcessing = false
return
}
// Escape backticks in the MathML string to prevent breaking the JS template literal
val mathMLForJs = job.mathML.replace("`", "\\`")
val script = """
(function() {
console.log("MATH_DIAGNOSTIC: Starting MathML to SVG conversion.");
const mathMLContent = `${mathMLForJs}`;
console.log("MATH_DIAGNOSTIC: Input MathML: " + mathMLContent);
MathJax.mathml2svgPromise(mathMLContent).then(function (node) {
console.log("MATH_DIAGNOSTIC: mathml2svgPromise successful.");
var svgElement = node.querySelector('svg');
if (svgElement) {
svgElement.style.fill = 'currentColor';
var svgOutput = svgElement.outerHTML;
var width = svgElement.getAttribute('width');
var height = svgElement.getAttribute('height');
var viewBox = svgElement.getAttribute('viewBox');
console.log('MATH_SIZE_DIAGNOSTIC: Generated SVG details -> width: ' + width + ', height: ' + height + ', viewBox: ' + viewBox + ', length: ' + svgOutput.length);
console.log("MATH_DIAGNOSTIC: SVG generated: " + svgOutput);
AndroidBridge.onSvgReady(svgOutput);
} else {
console.error("MATH_DIAGNOSTIC: SVG element not found in MathJax output.");
AndroidBridge.onSvgReady('');
}
}).catch((err) => {
console.error("MATH_DIAGNOSTIC: MathJax conversion error:", err);
AndroidBridge.onSvgReady('');
});
})();
""".trimIndent()
webView?.evaluateJavascript(script, null)
}
private fun completeCurrentJob(result: RenderResult) {
val job = synchronized(jobQueue) {
if (jobQueue.isNotEmpty()) jobQueue.removeAt(0) else null
}
job?.continuation?.invoke(result)
processNextJob()
}
fun destroy() {
handler.post {
webView?.destroy()
webView = null
Timber.d("MathMLRenderer WebView destroyed.")
}
synchronized(jobQueue) {
jobQueue.clear()
isProcessing = false
}
}
private inner class WebAppInterface(private val onResult: (String) -> Unit) {
@Suppress("unused")
@JavascriptInterface
fun onSvgReady(svg: String) {
if (svg.isNotBlank()) {
Timber.d("onSvgReady SUCCESS. Received SVG length: ${svg.length}")
onResult(svg)
} else {
Timber.e("onSvgReady FAILURE. Received empty SVG.")
val job = synchronized(jobQueue) { jobQueue.firstOrNull() }
val altText = job?.mathML?.substringAfter("alttext=\"", "")?.substringBefore("\"") ?: "MathML rendering failed"
completeCurrentJob(RenderResult.Failure(altText))
}
}
@Suppress("unused")
@JavascriptInterface
fun onMathJaxReady() {
isMathJaxReady = true
Timber.d("onMathJaxReady: MathJax is ready.")
if (!readySignal.isCompleted) {
readySignal.complete(true)
}
}
}
}

View file

@ -0,0 +1,59 @@
package com.aryan.reader.paginatedreader
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.isSpecified
import com.aryan.reader.epub.EpubChapter
import kotlin.math.ceil
import kotlin.math.max
object PageCountEstimator {
/**
* heuristic factor: approximate percentage of HTML string that is actual text vs tags.
* 0.6 means we assume 60% of the string length is visible text.
*/
private const val HTML_TEXT_DENSITY_FACTOR = 0.6f
/**
* Calculates an approximate page count instantly without rendering.
*/
fun estimateChapterPageCount(
chapter: EpubChapter,
constraints: Constraints,
textStyle: TextStyle,
density: Density
): Int {
val screenWidth = constraints.maxWidth
val screenHeight = constraints.maxHeight
val screenArea = screenWidth * screenHeight
if (screenArea <= 0) return 1
val fontSizePx = with(density) { textStyle.fontSize.toPx() }
val lineHeightPx = if (textStyle.lineHeight.isSpecified) {
with(density) { textStyle.lineHeight.toPx() }
} else {
fontSizePx * 1.4f
}
val avgCharWidthPx = fontSizePx * 0.6f
val charArea = avgCharWidthPx * lineHeightPx
val rawCharsPerPage = screenArea / charArea
val packingFactor = 0.75f
val estimatedVisibleCharsPerPage = (rawCharsPerPage * packingFactor).toInt()
if (estimatedVisibleCharsPerPage <= 0) return 1
val estimatedTextLength = (chapter.htmlContent.length * HTML_TEXT_DENSITY_FACTOR).toInt()
val pages = ceil(estimatedTextLength.toFloat() / estimatedVisibleCharsPerPage).toInt()
return max(1, pages)
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,353 @@
// PaginatedReaderData.kt
@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 border: BorderStyle? = null,
@ProtoNumber(8) val listStyleType: String? = null,
@ProtoNumber(9) val listStyleImage: String? = null,
@ProtoNumber(10) val pageBreakInsideAvoid: Boolean = false,
@ProtoNumber(11) val pageBreakAfterAvoid: Boolean = false,
@ProtoNumber(12) val boxSizing: String? = null,
@ProtoNumber(13) val float: String? = null,
@ProtoNumber(14) val clear: String? = null,
@ProtoNumber(15) val position: String? = null,
@ProtoNumber(16) @Serializable(with = DpSerializer::class) val top: Dp = Dp.Unspecified,
@ProtoNumber(17) @Serializable(with = DpSerializer::class) val right: Dp = Dp.Unspecified,
@ProtoNumber(18) @Serializable(with = DpSerializer::class) val bottom: Dp = Dp.Unspecified,
@ProtoNumber(19) @Serializable(with = DpSerializer::class) val left: Dp = Dp.Unspecified,
@ProtoNumber(20) val display: String? = null,
@ProtoNumber(21) val flexDirection: String? = null,
@ProtoNumber(22) val justifyContent: String? = null,
@ProtoNumber(23) val alignItems: String? = null,
@ProtoNumber(24) val horizontalAlign: String? = null,
@ProtoNumber(25) val filter: String? = null,
@ProtoNumber(26) val borderCollapse: String? = null,
@ProtoNumber(27) @Serializable(with = DpSerializer::class) val borderSpacing: Dp = 0.dp,
@ProtoNumber(28) @Serializable(with = DpSerializer::class) val borderRadius: 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,
border = other.border ?: this.border,
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,
borderRadius = if (other.borderRadius != 0.dp) other.borderRadius else this.borderRadius
)
}
}
@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
}
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
) : 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
) : 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,
) : 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
) : 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
) : 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
) : 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
) : 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
) : 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
) : 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
) {
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
)
}
}
@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
) : 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 {
val mergedByTag = (this.byTag.asSequence() + other.byTag.asSequence())
.groupBy({ it.key }, { it.value })
.mapValues { (_, values) -> values.flatten() }
val mergedByClass = (this.byClass.asSequence() + other.byClass.asSequence())
.groupBy({ it.key }, { it.value })
.mapValues { (_, values) -> values.flatten() }
val mergedById = (this.byId.asSequence() + other.byId.asSequence())
.groupBy({ it.key }, { it.value })
.mapValues { (_, values) -> values.flatten() }
val mergedOtherComplex = this.otherComplex + other.otherComplex
return OptimizedCssRules(mergedByTag, mergedByClass, mergedById, mergedOtherComplex)
}
fun toFlatList(): List<CssRule> {
return byTag.values.flatten() + byClass.values.flatten() + byId.values.flatten() + otherComplex
}
}
data class OptimizedCssParseResult(
val rules: OptimizedCssRules,
val fontFaces: List<FontFaceInfo>
)

View file

@ -0,0 +1,149 @@
// PaginatedReaderViewModel.kt
package com.aryan.reader.paginatedreader
import android.content.Context
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.protobuf.ProtoBuf
data class PaginatedReaderUiState(
val isLoading: Boolean = true,
val totalPageCount: Int = 0,
val generation: Int = 0
)
@OptIn(ExperimentalSerializationApi::class)
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
class PaginatedReaderViewModel : ViewModel() {
@VisibleForTesting
internal var paginator: IPaginator? = null
private set
private val _uiState = MutableStateFlow(PaginatedReaderUiState())
val uiState: StateFlow<PaginatedReaderUiState> = _uiState.asStateFlow()
@VisibleForTesting
internal fun setPaginatorForTest(testPaginator: IPaginator) {
paginator = testPaginator
observePaginatorState()
}
companion object {
val proto = ProtoBuf { serializersModule = semanticBlockModule }
}
fun initialize(
book: EpubBook,
textMeasurer: TextMeasurer,
textConstraints: Constraints,
textStyle: TextStyle,
density: Density,
isDarkTheme: Boolean,
context: Context,
initialChapterToPaginate: Int?,
mathMLRenderer: MathMLRenderer
) {
if (paginator != null) return
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true)
// CSS Parsing and Font Loading
val userAgentStylesheet = UserAgentStylesheet.default
var allRules = OptimizedCssRules() // CHANGED from: mutableListOf<CssRule>()
val allFontFaces = mutableListOf<FontFaceInfo>()
val uaResult = CssParser.parse(
cssContent = userAgentStylesheet,
cssPath = null,
baseFontSizeSp = textStyle.fontSize.value,
density = density.density,
constraints = textConstraints,
isDarkTheme = isDarkTheme
)
allRules = allRules.merge(uaResult.rules) // CHANGED
allFontFaces.addAll(uaResult.fontFaces)
book.css.forEach { (path, content) ->
val bookCssResult = CssParser.parse(
cssContent = content,
cssPath = path,
baseFontSizeSp = textStyle.fontSize.value,
density = density.density,
constraints = textConstraints,
isDarkTheme = isDarkTheme
)
allRules = allRules.merge(bookCssResult.rules) // CHANGED
allFontFaces.addAll(bookCssResult.fontFaces)
}
val fontFamilyMap = loadFontFamilies(
fontFaces = allFontFaces,
extractionPath = book.extractionBasePath
)
val bookId = book.title
val bookCacheDao = BookCacheDatabase.getDatabase(context.applicationContext).bookCacheDao()
val newPaginator = BookPaginator(
coroutineScope = viewModelScope,
chapters = book.chaptersForPagination,
textMeasurer = textMeasurer,
constraints = textConstraints,
textStyle = textStyle,
extractionBasePath = book.extractionBasePath,
density = density,
fontFamilyMap = fontFamilyMap,
isDarkTheme = isDarkTheme,
bookId = bookId,
bookCacheDao = bookCacheDao,
proto = proto,
initialChapterToPaginate = initialChapterToPaginate ?: 0,
bookCss = book.css,
userAgentStylesheet = userAgentStylesheet,
allFontFaces = allFontFaces,
context = context.applicationContext,
mathMLRenderer = mathMLRenderer,
userTextAlign = null
)
paginator = newPaginator
observePaginatorState()
}
}
private fun observePaginatorState() {
val p = paginator ?: return
viewModelScope.launch {
snapshotFlow { p.isLoading }.collect {
_uiState.value = _uiState.value.copy(isLoading = it)
}
}
viewModelScope.launch {
snapshotFlow { p.totalPageCount }.collect {
_uiState.value = _uiState.value.copy(totalPageCount = it)
}
}
viewModelScope.launch {
snapshotFlow { p.generation }.collect {
_uiState.value = _uiState.value.copy(generation = it)
}
}
}
fun onLinkClick(currentChapterPath: String, href: String, onNavigationComplete: (Int) -> Unit) {
paginator?.navigateToHref(currentChapterPath, href, onNavigationComplete)
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,184 @@
// SemanticModel.kt
@file:OptIn(ExperimentalSerializationApi::class)
package com.aryan.reader.paginatedreader
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
@Serializable
sealed interface SemanticBlock {
val elementId: String?
val cfi: String?
val style: CssStyle
val blockIndex: Int
}
@Serializable
data class SemanticSpan(
@ProtoNumber(1) val start: Int,
@ProtoNumber(2) val end: Int,
@ProtoNumber(3) val style: CssStyle,
@ProtoNumber(4) val linkHref: String? = null,
@ProtoNumber(5) val tag: String,
@ProtoNumber(6) val elementId: String? = null // Add this
)
fun SemanticBlock.withElementId(id: String): SemanticBlock {
if (this.elementId != null) return this
return when (this) {
is SemanticParagraph -> this.copy(elementId = id)
is SemanticHeader -> this.copy(elementId = id)
is SemanticListItem -> this.copy(elementId = id)
is SemanticList -> this.copy(elementId = id)
is SemanticImage -> this.copy(elementId = id)
is SemanticMath -> this.copy(elementId = id)
is SemanticSpacer -> this.copy(elementId = id)
is SemanticTable -> this.copy(elementId = id)
is SemanticFlexContainer -> this.copy(elementId = id)
is SemanticWrappingBlock -> this.copy(elementId = id)
is SemanticTextBlock -> this
}
}
interface SemanticTextBlock : SemanticBlock {
val text: String
val spans: List<SemanticSpan>
val startCharOffsetInSource: Int
}
@Serializable
data class SemanticParagraph(
@ProtoNumber(1) override val text: String,
@ProtoNumber(2) override val spans: List<SemanticSpan>,
@ProtoNumber(3) override val style: CssStyle,
@ProtoNumber(4) override val elementId: String?,
@ProtoNumber(5) override val cfi: String?,
@ProtoNumber(6) override val startCharOffsetInSource: Int = 0,
@ProtoNumber(7) override val blockIndex: Int = 0
) : SemanticTextBlock
@Serializable
data class SemanticHeader(
@ProtoNumber(1) val level: Int,
@ProtoNumber(2) override val text: String,
@ProtoNumber(3) override val spans: List<SemanticSpan>,
@ProtoNumber(4) override val style: CssStyle,
@ProtoNumber(5) override val elementId: String?,
@ProtoNumber(6) override val cfi: String?,
@ProtoNumber(7) override val startCharOffsetInSource: Int = 0,
@ProtoNumber(8) override val blockIndex: Int = 0
) : SemanticTextBlock
@Serializable
data class SemanticListItem(
@ProtoNumber(1) override val text: String,
@ProtoNumber(2) override val spans: List<SemanticSpan>,
@ProtoNumber(3) override val style: CssStyle,
@ProtoNumber(4) override val elementId: String?,
@ProtoNumber(5) override val cfi: String?,
@ProtoNumber(6) override val startCharOffsetInSource: Int = 0,
@ProtoNumber(7) val itemMarkerImage: String?,
@ProtoNumber(8) override val blockIndex: Int = 0
) : SemanticTextBlock
@Serializable
data class SemanticList(
@ProtoNumber(1) val items: List<SemanticListItem>,
@ProtoNumber(2) val isOrdered: Boolean,
@ProtoNumber(3) override val style: CssStyle,
@ProtoNumber(4) override val elementId: String?,
@ProtoNumber(5) override val cfi: String?,
@ProtoNumber(6) override val blockIndex: Int = 0
) : SemanticBlock
@Serializable
data class SemanticImage(
@ProtoNumber(1) val path: String, // Will store the absolute path
@ProtoNumber(2) val altText: String?,
@ProtoNumber(3) val intrinsicWidth: Float?,
@ProtoNumber(4) val intrinsicHeight: Float?,
@ProtoNumber(5) override val style: CssStyle,
@ProtoNumber(6) override val elementId: String?,
@ProtoNumber(7) override val cfi: String?,
@ProtoNumber(8) override val blockIndex: Int = 0
) : SemanticBlock
@Serializable
data class SemanticMath(
@ProtoNumber(1) val svgContent: String?,
@ProtoNumber(2) val altText: String?,
@ProtoNumber(3) val svgWidth: String?,
@ProtoNumber(4) val svgHeight: String?,
@ProtoNumber(5) val svgViewBox: String?,
@ProtoNumber(6) val isFromMathJax: Boolean,
@ProtoNumber(7) override val style: CssStyle,
@ProtoNumber(8) override val elementId: String?,
@ProtoNumber(9) override val cfi: String?,
@ProtoNumber(10) override val blockIndex: Int = 0
) : SemanticBlock
@Serializable
data class SemanticSpacer(
@ProtoNumber(1) override val style: CssStyle,
@ProtoNumber(2) override val elementId: String?,
@ProtoNumber(3) override val cfi: String?,
@ProtoNumber(4) val isExplicitLineBreak: Boolean = false,
@ProtoNumber(5) override val blockIndex: Int = 0
) : SemanticBlock
@Serializable
data class SemanticTableCell(
@ProtoNumber(1) val content: List<SemanticBlock>,
@ProtoNumber(2) val isHeader: Boolean,
@ProtoNumber(3) val colspan: Int,
@ProtoNumber(4) val style: CssStyle
)
@Serializable
data class SemanticTable(
@ProtoNumber(1) val rows: List<List<SemanticTableCell>>,
@ProtoNumber(2) override val style: CssStyle,
@ProtoNumber(3) override val elementId: String?,
@ProtoNumber(4) override val cfi: String?,
@ProtoNumber(5) override val blockIndex: Int = 0
) : SemanticBlock
@Serializable
data class SemanticFlexContainer(
@ProtoNumber(1) val children: List<SemanticBlock>,
@ProtoNumber(2) override val style: CssStyle,
@ProtoNumber(3) override val elementId: String?,
@ProtoNumber(4) override val cfi: String?,
@ProtoNumber(5) override val blockIndex: Int = 0
) : SemanticBlock
@Serializable
data class SemanticWrappingBlock(
@ProtoNumber(1) val floatedImage: SemanticImage,
@ProtoNumber(2) val paragraphsToWrap: List<SemanticParagraph>,
@ProtoNumber(3) override val style: CssStyle,
@ProtoNumber(4) override val elementId: String?,
@ProtoNumber(5) override val cfi: String?,
@ProtoNumber(6) override val blockIndex: Int = 0
) : SemanticBlock
val semanticBlockModule = SerializersModule {
polymorphic(SemanticBlock::class) {
subclass(SemanticParagraph::class)
subclass(SemanticHeader::class)
subclass(SemanticListItem::class)
subclass(SemanticList::class)
subclass(SemanticImage::class)
subclass(SemanticMath::class)
subclass(SemanticSpacer::class)
subclass(SemanticTable::class)
subclass(SemanticFlexContainer::class)
subclass(SemanticWrappingBlock::class)
}
}

View file

@ -0,0 +1,81 @@
// StyleUtils.kt
package com.aryan.reader.paginatedreader
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
fun parseCssDimensionToTextUnit(
value: String,
containerWidthPx: Int,
density: Float
): TextUnit {
if (density <= 0) return TextUnit.Unspecified
val sanitizedValue = value.trim().lowercase()
return when {
sanitizedValue.endsWith("rem") -> sanitizedValue.removeSuffix("rem").toFloatOrNull()?.em ?: TextUnit.Unspecified
sanitizedValue.endsWith("em") -> sanitizedValue.removeSuffix("em").toFloatOrNull()?.em ?: TextUnit.Unspecified
sanitizedValue.endsWith("px") -> {
val px = sanitizedValue.removeSuffix("px").toFloatOrNull() ?: 0f
(px / density).sp
}
sanitizedValue.endsWith("pt") -> {
val pt = sanitizedValue.removeSuffix("pt").toFloatOrNull() ?: 0f
val px = pt * (4f / 3f)
(px / density).sp
}
sanitizedValue.endsWith("%") -> {
val percentage = sanitizedValue.removeSuffix("%").toFloatOrNull() ?: 0f
if (containerWidthPx > 0) {
val px = (percentage / 100f) * containerWidthPx
(px / density).sp
} else {
TextUnit.Unspecified
}
}
else -> TextUnit.Unspecified
}
}
fun parseCssSizeToDp(
value: String,
baseFontSizeSp: Float,
density: Float,
containerWidthPx: Int
): Dp {
if (density <= 0) return 0.dp
val sanitizedValue = value.trim().lowercase()
return when {
sanitizedValue.endsWith("px") -> {
val px = sanitizedValue.removeSuffix("px").toFloatOrNull() ?: 0f
(px / density).dp
}
sanitizedValue.endsWith("rem") -> {
val rem = sanitizedValue.removeSuffix("rem").toFloatOrNull() ?: 0f
(rem * baseFontSizeSp).dp
}
sanitizedValue.endsWith("em") -> {
val em = sanitizedValue.removeSuffix("em").toFloatOrNull() ?: 0f
(em * baseFontSizeSp).dp
}
sanitizedValue.endsWith("pt") -> {
val pt = sanitizedValue.removeSuffix("pt").toFloatOrNull() ?: 0f
val px = pt * (4f / 3f)
(px / density).dp
}
sanitizedValue.endsWith("%") -> {
val percentage = sanitizedValue.removeSuffix("%").toFloatOrNull() ?: 0f
if (containerWidthPx > 0) {
val px = (percentage / 100f) * containerWidthPx
(px / density).dp
} else {
0.dp
}
}
else -> 0.dp
}
}

View file

@ -0,0 +1,43 @@
// SvgStringFetcher.kt
package com.aryan.reader.paginatedreader
import timber.log.Timber
import coil.decode.DataSource
import coil.decode.ImageSource
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.request.Options
import okio.Buffer
/**
* A custom data class to wrap raw SVG string content.
* This avoids conflicts with Coil's default String fetcher.
*/
data class SvgData(val content: String)
/**
* A custom Coil Fetcher that handles loading SVG data from our [SvgData] class.
*/
class SvgStringFetcher(
private val options: Options,
private val data: SvgData,
) : Fetcher {
override suspend fun fetch(): FetchResult {
Timber.d("SvgStringFetcher: fetching SVG data from SvgData object.")
val buffer = Buffer().writeUtf8(data.content)
return SourceResult(
source = ImageSource(buffer, options.context),
mimeType = "image/svg+xml",
dataSource = DataSource.MEMORY
)
}
class Factory : Fetcher.Factory<SvgData> {
override fun create(data: SvgData, options: Options, imageLoader: coil.ImageLoader): Fetcher {
Timber.d("SvgStringFetcher.Factory: create called for SvgData.")
return SvgStringFetcher(options, data)
}
}
}

View file

@ -0,0 +1,99 @@
// UserAgentStylesheet.kt
package com.aryan.reader.paginatedreader
object UserAgentStylesheet {
val default: String = """
/* Basic inline formatting */
b, strong {
font-weight: bold;
}
i, em, cite, dfn {
font-style: italic;
}
u {
text-decoration: underline;
}
s, strike, del {
text-decoration: line-through;
}
code, kbd, samp, tt, pre {
font-family: monospace;
}
/* Basic block elements */
h1 {
font-size: 2em;
font-weight: bold;
margin-top: 0.67em;
margin-bottom: 0.67em;
}
h2 {
font-size: 1.5em;
font-weight: bold;
margin-top: 0.83em;
margin-bottom: 0.83em;
}
h3 {
font-size: 1.17em;
font-weight: bold;
margin-top: 1em;
margin-bottom: 1em;
}
h4 {
font-size: 1em;
font-weight: bold;
margin-top: 1.33em;
margin-bottom: 1.33em;
}
h5 {
font-size: 0.83em;
font-weight: bold;
margin-top: 1.67em;
margin-bottom: 1.67em;
}
h6 {
font-size: 0.67em;
font-weight: bold;
margin-top: 2.33em;
margin-bottom: 2.33em;
}
p {
margin-top: 1em;
margin-bottom: 1em;
}
div {
margin-top: 0;
margin-bottom: 0;
}
blockquote {
margin-top: 1em;
margin-bottom: 1em;
margin-left: 40px;
margin-right: 40px;
}
dl {
margin-top: 1em;
margin-bottom: 1em;
}
dt {
font-weight: bold;
}
dd {
margin-left: 40px;
}
ul, ol {
margin-top: 1em;
margin-bottom: 1em;
padding-left: 40px;
}
li {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
hr {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
""".trimIndent()
}

View file

@ -0,0 +1,16 @@
package com.aryan.reader.paginatedreader
object Woff2Converter {
init {
System.loadLibrary("native-lib")
}
/**
* Converts a WOFF2 font file into a TTF font file.
*
* @param woff2Data The raw byte array of the WOFF2 file.
* @return A byte array of the converted TTF file, or null if conversion fails.
*/
external fun convertWoff2ToTtf(woff2Data: ByteArray): ByteArray?
}

View file

@ -0,0 +1,178 @@
// BookCacheDatabase.kt
package com.aryan.reader.paginatedreader.data
import android.content.Context
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.Transaction
@Dao
abstract class BookCacheDao {
// --- Book Operations ---
@Query("SELECT * FROM processed_books WHERE bookId = :bookId")
abstract suspend fun getProcessedBook(bookId: String): ProcessedBook?
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insertProcessedBook(book: ProcessedBook)
@Query("DELETE FROM processed_books WHERE bookId = :bookId")
abstract suspend fun deleteBook(bookId: String)
@Query("DELETE FROM processed_books")
abstract suspend fun clearProcessedBooks()
// --- Chapter Operations (Internal Raw Access) ---
@Query("SELECT * FROM processed_chapter_metadata WHERE book_id = :bookId AND chapter_index = :chapterIndex")
protected abstract suspend fun getChapterMetadata(bookId: String, chapterIndex: Int): ProcessedChapterMetadata?
@Query("SELECT chunk_data FROM processed_chapter_chunks WHERE book_id = :bookId AND chapter_index = :chapterIndex ORDER BY chunk_index ASC")
protected abstract suspend fun getChapterChunks(bookId: String, chapterIndex: Int): List<ByteArray>
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract suspend fun insertChapterMetadata(metadata: ProcessedChapterMetadata)
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract suspend fun insertChapterChunks(chunks: List<ProcessedChapterChunk>)
@Query("DELETE FROM processed_chapter_metadata WHERE book_id = :bookId")
protected abstract suspend fun deleteChapterMetadataForBook(bookId: String)
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insertAnchorIndices(anchors: List<AnchorIndexEntry>)
@Query("SELECT * FROM anchor_index WHERE bookId = :bookId AND anchorId = :anchorId LIMIT 1")
abstract suspend fun getAnchorIndex(bookId: String, anchorId: String): AnchorIndexEntry?
@Query("DELETE FROM anchor_index WHERE bookId = :bookId")
abstract suspend fun deleteAnchorsForBook(bookId: String)
@Transaction
open suspend fun getProcessedChapter(bookId: String, chapterIndex: Int): ProcessedChapter? {
val metadata = getChapterMetadata(bookId, chapterIndex) ?: return null
val chunks = getChapterChunks(bookId, chapterIndex)
if (chunks.isEmpty()) {
return ProcessedChapter(bookId, chapterIndex, ByteArray(0), metadata.estimatedPageCount)
}
val totalSize = chunks.sumOf { it.size }
val mergedData = ByteArray(totalSize)
var offset = 0
for (chunk in chunks) {
System.arraycopy(chunk, 0, mergedData, offset, chunk.size)
offset += chunk.size
}
return ProcessedChapter(
bookId = bookId,
chapterIndex = chapterIndex,
contentBlocksProto = mergedData,
estimatedPageCount = metadata.estimatedPageCount
)
}
@Transaction
open suspend fun insertProcessedChapters(chapters: List<ProcessedChapter>) {
@Suppress("LocalVariableName") val CHUNK_SIZE = 900 * 1024
for (chapter in chapters) {
val metadata = ProcessedChapterMetadata(
bookId = chapter.bookId,
chapterIndex = chapter.chapterIndex,
estimatedPageCount = chapter.estimatedPageCount
)
insertChapterMetadata(metadata)
val fullData = chapter.contentBlocksProto
if (fullData.isEmpty()) continue
val chunks = ArrayList<ProcessedChapterChunk>()
var offset = 0
var chunkIndex = 0
while (offset < fullData.size) {
val end = (offset + CHUNK_SIZE).coerceAtMost(fullData.size)
val chunkBytes = fullData.copyOfRange(offset, end)
chunks.add(
ProcessedChapterChunk(
bookId = chapter.bookId,
chapterIndex = chapter.chapterIndex,
chunkIndex = chunkIndex,
chunkData = chunkBytes
)
)
offset = end
chunkIndex++
}
insertChapterChunks(chunks)
}
}
@Transaction
open suspend fun deleteChaptersForBook(bookId: String) {
deleteChapterMetadataForBook(bookId)
}
@Transaction
open suspend fun clearProcessedChapters() {
deleteAllChapterMetadata()
}
@Query("DELETE FROM processed_chapter_metadata")
protected abstract suspend fun deleteAllChapterMetadata()
@Transaction
open suspend fun clearAllCache() {
clearProcessedBooks()
clearProcessedChapters()
}
@Query("SELECT * FROM configuration_cache WHERE bookId = :bookId AND configHash = :configHash")
abstract suspend fun getConfigurationCache(bookId: String, configHash: Int): ConfigurationCache?
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insertConfigurationCache(cache: ConfigurationCache)
}
@Database(
entities = [
ProcessedBook::class,
ProcessedChapterMetadata::class,
ProcessedChapterChunk::class,
ConfigurationCache::class,
AnchorIndexEntry::class
],
version = 6,
exportSchema = false
)
abstract class BookCacheDatabase : RoomDatabase() {
abstract fun bookCacheDao(): BookCacheDao
companion object {
@Volatile
private var INSTANCE: BookCacheDatabase? = null
fun getDatabase(context: Context): BookCacheDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
BookCacheDatabase::class.java,
"book_cache_database"
)
.fallbackToDestructiveMigration(true)
.build()
INSTANCE = instance
instance
}
}
}
}

View file

@ -0,0 +1,115 @@
// BookCacheEntities.kt
package com.aryan.reader.paginatedreader.data
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
const val LATEST_PROCESSING_VERSION = 6
@Entity(tableName = "processed_books")
data class ProcessedBook(
@PrimaryKey
val bookId: String,
val processingVersion: Int,
val totalPageCountEstimate: Int
)
@Entity(
tableName = "anchor_index",
primaryKeys = ["bookId", "anchorId"],
indices = [Index(value = ["bookId", "anchorId"])]
)
data class AnchorIndexEntry(
val bookId: String,
val anchorId: String,
val chapterIndex: Int,
val blockIndex: Int
)
data class ProcessedChapter(
val bookId: String,
val chapterIndex: Int,
val contentBlocksProto: ByteArray,
val estimatedPageCount: Int
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ProcessedChapter
if (bookId != other.bookId) return false
if (chapterIndex != other.chapterIndex) return false
if (!contentBlocksProto.contentEquals(other.contentBlocksProto)) return false
if (estimatedPageCount != other.estimatedPageCount) return false
return true
}
override fun hashCode(): Int {
var result = bookId.hashCode()
result = 31 * result + chapterIndex
result = 31 * result + contentBlocksProto.contentHashCode()
result = 31 * result + estimatedPageCount
return result
}
}
/**
* Database Entity: Stores metadata only (small size).
*/
@Entity(tableName = "processed_chapter_metadata", primaryKeys = ["book_id", "chapter_index"])
data class ProcessedChapterMetadata(
@ColumnInfo(name = "book_id") val bookId: String,
@ColumnInfo(name = "chapter_index") val chapterIndex: Int,
@ColumnInfo(name = "estimated_page_count") val estimatedPageCount: Int
)
/**
* Database Entity: Stores the blob data in 1MB chunks to avoid CursorWindow limits.
*/
@Entity(
tableName = "processed_chapter_chunks",
primaryKeys = ["book_id", "chapter_index", "chunk_index"],
foreignKeys = [
ForeignKey(
entity = ProcessedChapterMetadata::class,
parentColumns = ["book_id", "chapter_index"],
childColumns = ["book_id", "chapter_index"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index(value = ["book_id", "chapter_index"])]
)
data class ProcessedChapterChunk(
@ColumnInfo(name = "book_id") val bookId: String,
@ColumnInfo(name = "chapter_index") val chapterIndex: Int,
@ColumnInfo(name = "chunk_index") val chunkIndex: Int,
@ColumnInfo(name = "chunk_data", typeAffinity = ColumnInfo.BLOB) val chunkData: ByteArray
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ProcessedChapterChunk
if (bookId != other.bookId) return false
if (chapterIndex != other.chapterIndex) return false
if (chunkIndex != other.chunkIndex) return false
if (!chunkData.contentEquals(other.chunkData)) return false
return true
}
override fun hashCode(): Int {
var result = bookId.hashCode()
result = 31 * result + chapterIndex
result = 31 * result + chunkIndex
result = 31 * result + chunkData.contentHashCode()
return result
}
}
@Entity(tableName = "configuration_cache", primaryKeys = ["bookId", "configHash"])
data class ConfigurationCache(
val bookId: String,
val configHash: Int,
val chapterPageCounts: String
)

View file

@ -0,0 +1,356 @@
// BookProcessingWorker.kt
package com.aryan.reader.paginatedreader.data
import android.content.Context
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.text.TextStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.sp
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.aryan.reader.paginatedreader.CssParser
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.loadFontFamilies
import com.aryan.reader.paginatedreader.semanticBlockModule
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromByteArray
import kotlinx.serialization.encodeToByteArray
import kotlinx.serialization.protobuf.ProtoBuf
import kotlinx.serialization.protobuf.ProtoNumber
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import java.io.File
import java.net.URLDecoder
import kotlin.math.abs
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class SerializableEpubChapter(
@ProtoNumber(1) val htmlContent: String,
@ProtoNumber(2) val title: String,
@ProtoNumber(3) val absPath: String
)
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class BookProcessingInput(
@ProtoNumber(1) val chapters: List<SerializableEpubChapter>,
@ProtoNumber(2) val userAgentStylesheet: String,
@ProtoNumber(3) val bookCss: Map<String, String>,
@ProtoNumber(4) val baseFontSizeSp: Float,
@ProtoNumber(5) val density: Float,
@ProtoNumber(6) val constraintsMaxWidth: Int,
@ProtoNumber(7) val constraintsMaxHeight: Int,
@ProtoNumber(8) val fontFaces: List<FontFaceInfo> = emptyList()
)
@OptIn(ExperimentalSerializationApi::class)
class BookProcessingWorker(
private val appContext: Context,
workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
companion object {
const val WORK_TAG = "book-processing"
private const val KEY_BOOK_ID = "bookId"
private const val KEY_EXTRACTION_BASE_PATH = "extractionBasePath"
private const val KEY_INPUT_FILE_PATH = "inputFilePath"
private const val KEY_ESTIMATED_TOTAL_PAGES = "estimatedTotalPages"
private const val KEY_START_CHAPTER_INDEX = "startChapterIndex"
fun enqueue(
context: Context,
bookId: String,
extractionBasePath: String,
estimatedTotalPages: Int,
processingInput: BookProcessingInput,
startChapterIndex: Int
) {
val tempFile = File.createTempFile("proc_input_", ".proto", context.cacheDir)
val proto = ProtoBuf { serializersModule = semanticBlockModule }
tempFile.writeBytes(proto.encodeToByteArray(processingInput))
val workData = Data.Builder()
.putString(KEY_BOOK_ID, bookId)
.putString(KEY_EXTRACTION_BASE_PATH, extractionBasePath)
.putInt(KEY_ESTIMATED_TOTAL_PAGES, estimatedTotalPages)
.putString(KEY_INPUT_FILE_PATH, tempFile.absolutePath)
.putInt(KEY_START_CHAPTER_INDEX, startChapterIndex)
.build()
val workRequest = OneTimeWorkRequestBuilder<BookProcessingWorker>()
.setInputData(workData)
.addTag(WORK_TAG)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"process_$bookId",
androidx.work.ExistingWorkPolicy.KEEP,
workRequest
)
Timber.i("Enqueued background processing for book: $bookId")
}
}
private fun precalculateImageDimensions(
chapters: List<SerializableEpubChapter>,
extractionBasePath: String
): Map<String, Pair<Float, Float>> {
val dimensionsCache = mutableMapOf<String, Pair<Float, Float>>()
Timber.i("Starting pre-scan to calculate image dimensions...")
for (chapter in chapters) {
val document = Jsoup.parse(chapter.htmlContent)
val chapterParentPath = File(chapter.absPath).parent ?: ""
// Find all image tags (both <img> and <svg><image>)
document.select("img, image").forEach { element ->
val srcAttr = if (element.tagName() == "img") "src" else "href"
val src = element.attr(srcAttr).ifBlank { element.attr("xlink:href") }
if (src.isNotBlank()) {
val decodedSrc = try {
URLDecoder.decode(src, "UTF-8")
} catch (_: Exception) {
src
}
val imageFile = File(File(extractionBasePath, chapterParentPath), decodedSrc).canonicalFile
val imagePath = imageFile.absolutePath
// If not already cached, read dimensions from disk
if (imageFile.exists() && !dimensionsCache.containsKey(imagePath)) {
try {
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(imagePath, options)
if (options.outWidth > 0 && options.outHeight > 0) {
dimensionsCache[imagePath] = Pair(options.outWidth.toFloat(), options.outHeight.toFloat())
}
} catch (e: Exception) {
Timber.e(e, "Could not decode image bounds during pre-scan for $imagePath")
}
}
}
}
}
Timber.i("Pre-calculated dimensions for ${dimensionsCache.size} unique images.")
return dimensionsCache
}
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val bookId = inputData.getString(KEY_BOOK_ID) ?: return@withContext Result.failure()
val extractionBasePath = inputData.getString(KEY_EXTRACTION_BASE_PATH) ?: return@withContext Result.failure()
val estimatedTotalPages = inputData.getInt(KEY_ESTIMATED_TOTAL_PAGES, 0)
val inputFilePath = inputData.getString(KEY_INPUT_FILE_PATH) ?: return@withContext Result.failure()
val startChapterIndex = inputData.getInt(KEY_START_CHAPTER_INDEX, 0)
val inputFile = File(inputFilePath)
if (!inputFile.exists()) return@withContext Result.failure()
val proto = ProtoBuf { serializersModule = semanticBlockModule }
val db = BookCacheDatabase.getDatabase(appContext)
val mathMLRenderer = MathMLRenderer(appContext)
try {
Timber.i("Worker starting for book: $bookId")
if (!mathMLRenderer.awaitReady()) {
Timber.e("MathMLRenderer failed to initialize. Aborting processing for this book.")
return@withContext Result.failure()
}
val input = proto.decodeFromByteArray<BookProcessingInput>(inputFile.readBytes())
Timber.i("Worker decoded input. Number of chapters received: ${input.chapters.size}")
// Worker now reconstructs everything it needs for a pure light-theme processing run.
val density = Density(input.density)
val constraints = Constraints(maxWidth = input.constraintsMaxWidth, maxHeight = input.constraintsMaxHeight)
val textStyle = TextStyle(color = Color.Black, fontSize = input.baseFontSizeSp.sp) // Hardcode light theme values
var lightThemeCssRules = OptimizedCssRules()
val uaResult = CssParser.parse(
cssContent = input.userAgentStylesheet,
cssPath = null,
baseFontSizeSp = textStyle.fontSize.value,
density = density.density,
constraints = constraints,
isDarkTheme = false // GUARANTEED LIGHT THEME
)
lightThemeCssRules = lightThemeCssRules.merge(uaResult.rules)
input.bookCss.forEach { (path, content) ->
val bookCssResult = CssParser.parse(
cssContent = content,
cssPath = path,
baseFontSizeSp = textStyle.fontSize.value,
density = density.density,
constraints = constraints,
isDarkTheme = false // GUARANTEED LIGHT THEME
)
lightThemeCssRules = lightThemeCssRules.merge(bookCssResult.rules)
}
val imageDimensionsCache = precalculateImageDimensions(input.chapters, extractionBasePath)
val fontFamilyMap = loadFontFamilies(input.fontFaces, extractionBasePath)
val numCores = (Runtime.getRuntime().availableProcessors() / 2).coerceIn(1, 4)
val chaptersToProcess = input.chapters.withIndex().toList()
.sortedBy { (index, _) -> abs(index - startChapterIndex) }
Timber.d("Preparing to process ${chaptersToProcess.size} chapters.")
Timber.i("Worker processing with up to $numCores threads, prioritizing around chapter $startChapterIndex.")
chaptersToProcess.chunked(numCores).forEach { chunk ->
Timber.d("Processing a chunk of ${chunk.size} chapters.")
val deferreds = chunk.map { (index, chapter) ->
async {
Timber.d("Async task started for chapter index $index.")
if (db.bookCacheDao().getProcessedChapter(bookId, index) == null) {
Timber.d("[BG_PROC] Caching chapter $index: ${chapter.title}")
val document = Jsoup.parse(chapter.htmlContent, chapter.absPath)
val mathElements = document.select("math")
val svgResults = mutableMapOf<String, String>()
if (mathElements.isNotEmpty()) {
Timber.d("Chapter $index (Background Worker): Found ${mathElements.size} MathML elements to process.")
mathElements.forEachIndexed { i, element ->
val uniqueId = "math-ch${index}-eq${i}"
val altText = element.attr("alttext").ifBlank { "Equation" }
val placeholder = Element("math-placeholder").attr("id", uniqueId)
when (val result = mathMLRenderer.render(element.outerHtml(), altText)) {
is RenderResult.Success -> {
Timber.d("Chapter $index (Background Worker): Render SUCCESS for $uniqueId")
val svgDoc = Jsoup.parse(result.svg)
val svgElement = svgDoc.selectFirst("svg")
val width = svgElement?.attr("width") ?: "N/A"
val height = svgElement?.attr("height") ?: "N/A"
val viewBox = svgElement?.attr("viewBox") ?: "N/A"
Timber.d("Worker received SVG for '$uniqueId'. width: $width, height: $height, viewBox: $viewBox, length: ${result.svg.length}")
svgResults[uniqueId] = result.svg
}
is RenderResult.Failure -> {
Timber.w("Chapter $index (Background Worker): Render FAILURE for $uniqueId. Alt: ${result.altText}")
placeholder.attr("alttext", result.altText)
}
}
element.replaceWith(placeholder)
}
Timber.d("Chapter $index (Background Worker): Finished processing MathML. SVG cache has ${svgResults.size} items. Keys: ${svgResults.keys.joinToString()}")
}
val processedHtml = document.outerHtml()
Timber.d("Chapter $index (Background Worker): Processed HTML contains <math-placeholder>: ${processedHtml.contains("math-placeholder")}")
val semanticBlocks = htmlToSemanticBlocks(
html = processedHtml,
cssRules = lightThemeCssRules,
textStyle = textStyle,
chapterAbsPath = chapter.absPath,
extractionBasePath = extractionBasePath,
density = density,
fontFamilyMap = fontFamilyMap,
constraints = constraints,
imageDimensionsCache = imageDimensionsCache,
mathSvgCache = svgResults
)
val protoBytes = proto.encodeToByteArray(semanticBlocks)
ProcessedChapter(
bookId = bookId,
chapterIndex = index,
contentBlocksProto = protoBytes,
estimatedPageCount = 0
)
} else {
Timber.d("Chapter $index was already in the database. Skipping.")
null
}
}
}
val processedChapters = deferreds.awaitAll().filterNotNull()
if (processedChapters.isNotEmpty()) {
db.bookCacheDao().insertProcessedChapters(processedChapters)
val allAnchors = mutableListOf<AnchorIndexEntry>()
processedChapters.forEach { chapter ->
val blocks = proto.decodeFromByteArray<List<com.aryan.reader.paginatedreader.SemanticBlock>>(chapter.contentBlocksProto)
allAnchors.addAll(extractAnchorsFromBlocks(bookId, chapter.chapterIndex, blocks))
}
if (allAnchors.isNotEmpty()) {
db.bookCacheDao().insertAnchorIndices(allAnchors)
Timber.tag("TOC_NAV_DEBUG").d("Indexed ${allAnchors.size} anchors for chapters in this batch.")
}
Timber.i("Worker cached a batch of ${processedChapters.size} chapters for book $bookId.")
}
}
val finalBookRecord = ProcessedBook(bookId, LATEST_PROCESSING_VERSION, estimatedTotalPages)
db.bookCacheDao().insertProcessedBook(finalBookRecord)
Timber.i("[BG_PROC] Finished processing all chapters for book $bookId.")
return@withContext Result.success()
} catch (e: Exception) {
Timber.e(e, "Error in pagination worker for book $bookId")
return@withContext Result.failure()
} finally {
inputFile.delete()
mathMLRenderer.destroy()
}
}
private fun extractAnchorsFromBlocks(
bookId: String,
chapterIndex: Int,
blocks: List<com.aryan.reader.paginatedreader.SemanticBlock>
): List<AnchorIndexEntry> {
val anchors = mutableListOf<AnchorIndexEntry>()
fun walk(block: com.aryan.reader.paginatedreader.SemanticBlock) {
// 1. Check block ID
block.elementId?.let {
anchors.add(AnchorIndexEntry(bookId, it, chapterIndex, block.blockIndex))
}
// 2. Check Span IDs (Inline anchors)
if (block is com.aryan.reader.paginatedreader.SemanticTextBlock) {
block.spans.forEach { span ->
span.elementId?.let {
anchors.add(AnchorIndexEntry(bookId, it, chapterIndex, block.blockIndex))
}
}
}
// 3. Recurse
when (block) {
is com.aryan.reader.paginatedreader.SemanticFlexContainer -> block.children.forEach { walk(it) }
is com.aryan.reader.paginatedreader.SemanticTable -> block.rows.flatten().forEach { cell -> cell.content.forEach { walk(it) } }
is com.aryan.reader.paginatedreader.SemanticList -> block.items.forEach { walk(it) }
is com.aryan.reader.paginatedreader.SemanticWrappingBlock -> {
walk(block.floatedImage)
block.paragraphsToWrap.forEach { walk(it) }
}
else -> {}
}
}
blocks.forEach { walk(it) }
return anchors
}
}

View file

@ -0,0 +1,495 @@
// ComposeTypeSerializers.kt
@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
}
}