Initial commit
This commit is contained in:
commit
6072b2ba29
844 changed files with 220532 additions and 0 deletions
1196
app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt
Normal file
1196
app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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 "• "
|
||||
}
|
||||
}
|
||||
}
|
||||
859
app/src/main/java/com/aryan/reader/paginatedreader/CssParser.kt
Normal file
859
app/src/main/java/com/aryan/reader/paginatedreader/CssParser.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
143
app/src/main/java/com/aryan/reader/paginatedreader/FontLoader.kt
Normal file
143
app/src/main/java/com/aryan/reader/paginatedreader/FontLoader.kt
Normal 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!! }
|
||||
}
|
||||
577
app/src/main/java/com/aryan/reader/paginatedreader/HtmlParser.kt
Normal file
577
app/src/main/java/com/aryan/reader/paginatedreader/HtmlParser.kt
Normal 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++)
|
||||
}
|
||||
}
|
||||
|
|
@ -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?
|
||||
}
|
||||
262
app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt
Normal file
262
app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
1049
app/src/main/java/com/aryan/reader/paginatedreader/Paginator.kt
Normal file
1049
app/src/main/java/com/aryan/reader/paginatedreader/Paginator.kt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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?
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue