/*
* Episteme Reader - A native Android document reader.
* Copyright (C) 2026 Episteme
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*
* mail: epistemereader@gmail.com
*/
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.SpanStyle
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.style.TextDecoration
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
private const val DEBUG_CONTENT_STYLING = false
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
class ContentStyler(
private val baseTextStyle: TextStyle,
private val fontFamilyMap: Map,
private val density: Density,
private val isDarkTheme: Boolean,
private val themeBackgroundColor: Color,
private val themeTextColor: Color,
private val chapterAbsPath: String,
private val extractionBasePath: String,
private val userTextAlign: TextAlign?,
private val paragraphGapMultiplier: Float,
private val adaptThemeColors: Boolean = true
) {
fun style(semanticBlocks: List): List {
return groupFloatingBlocks(semanticBlocks.mapNotNull { styleBlock(it) })
}
private fun groupFloatingBlocks(blocks: List): List {
if (blocks.isEmpty()) return emptyList()
val result = mutableListOf()
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 paragraphsToWrap = mutableListOf()
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(
currentBlock,
paragraphsToWrap,
elementId = currentBlock.elementId,
cfi = currentBlock.cfi,
blockIndex = currentBlock.blockIndex
)
result.add(wrappingBlock)
} else {
result.add(currentBlock)
}
}
return result
}
private fun styleBlock(block: SemanticBlock): ContentBlock? {
val themedStyle = applyThemeToStyle(block.style)
val finalBlockStyle = if (block is SemanticParagraph) {
val originalMargin = themedStyle.blockStyle.margin
val newMargin = originalMargin.copy(
top = originalMargin.top * paragraphGapMultiplier,
bottom = originalMargin.bottom * paragraphGapMultiplier
)
themedStyle.blockStyle.copy(margin = newMargin)
} else {
themedStyle.blockStyle
}
return when (block) {
is SemanticParagraph -> {
val computedTextAlign = userTextAlign ?: themedStyle.paragraphStyle.textAlign
ParagraphBlock(
content = buildAnnotatedString(block, themedStyle),
textAlign = computedTextAlign,
style = finalBlockStyle,
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 svgContent = block.svgContent
val nonBlankSvgContent = svgContent?.takeIf { it.isNotBlank() }
val finalSvgContent = when {
block.isFromMathJax || nonBlankSvgContent == null -> svgContent
!adaptThemeColors -> embedImagesInSvg(nonBlankSvgContent)
else -> {
val themedSvg = applyThemeToSvg(nonBlankSvgContent)
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 {
if (!adaptThemeColors) {
return style
}
val newSpanStyle = style.spanStyle.let { original ->
val newColor = if (original.color.isSpecified) {
CssParser.adaptColorForTheme(original.color, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
} 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, themeBackgroundColor, themeTextColor)
} else {
original.backgroundColor
}
fun themeBorder(b: BorderStyle?): BorderStyle? {
if (b == null) return null
val newColor = CssParser.adaptColorForTheme(b.color, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
return b.copy(color = newColor)
}
original.copy(
backgroundColor = newBgColor,
borderTop = themeBorder(original.borderTop),
borderRight = themeBorder(original.borderRight),
borderBottom = themeBorder(original.borderBottom),
borderLeft = themeBorder(original.borderLeft)
)
}
val newTextDecorationColor = if (style.textDecorationColor.isSpecified) {
CssParser.adaptColorForTheme(style.textDecorationColor, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
} else {
style.textDecorationColor
}
return style.copy(
spanStyle = newSpanStyle,
blockStyle = newBlockStyle,
textDecorationColor = newTextDecorationColor
)
}
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 {
if (DEBUG_CONTENT_STYLING) {
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
)
val isCustomFont = baseTextStyle.fontFamily != null && baseTextStyle.fontFamily != FontFamily.Default
val effectiveBlockFontFamily = if (rootFontFamily == FontFamily.Monospace) {
FontFamily.Monospace
} else if (isCustomFont) {
baseTextStyle.fontFamily
} else {
rootFontFamily ?: baseTextStyle.fontFamily
}
val initialSpanStyle = baseTextStyle.toSpanStyle()
.merge(blockStyle.spanStyle)
.copy(fontFamily = effectiveBlockFontFamily)
if (DEBUG_CONTENT_STYLING) {
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 spanFontFamily = findFirstAvailableFontFamily(themedSpanStyle.fontFamilies, fontFamilyMap)
val effectiveSpanFontFamily = if (spanFontFamily == FontFamily.Monospace) {
FontFamily.Monospace
} else if (isCustomFont) {
baseTextStyle.fontFamily
} else {
spanFontFamily
}
val baselineShift = when (span.tag) {
"sub" -> BaselineShift.Subscript
"sup" -> BaselineShift.Superscript
else -> null
}
var finalSpanStyle = themedSpanStyle.spanStyle.copy(
fontFamily = effectiveSpanFontFamily,
baselineShift = baselineShift
)
if (!span.linkHref.isNullOrBlank()) {
finalSpanStyle = finalSpanStyle.withReaderLinkStyle(
isDarkTheme = isDarkTheme,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
)
}
val hasCustomDeco = themedSpanStyle.textDecorationStyle != null ||
themedSpanStyle.textDecorationColor.isSpecified ||
themedSpanStyle.textUnderlineOffset.isSpecified
val combinedDeco = finalSpanStyle.textDecoration ?: TextDecoration.None
if (hasCustomDeco && combinedDeco.contains(TextDecoration.Underline)) {
val decos = mutableListOf()
if (combinedDeco.contains(TextDecoration.LineThrough)) decos.add(TextDecoration.LineThrough)
finalSpanStyle = finalSpanStyle.copy(
textDecoration = if (decos.isNotEmpty()) TextDecoration.combine(decos) else TextDecoration.None
)
val styleStr = themedSpanStyle.textDecorationStyle ?: "solid"
val colorStr = if (themedSpanStyle.textDecorationColor.isSpecified) themedSpanStyle.textDecorationColor.value.toString() else "Unspecified"
val offsetStr = if (themedSpanStyle.textUnderlineOffset.isSpecified) themedSpanStyle.textUnderlineOffset.value.toString() else "0"
val annotationData = "$styleStr|$colorStr|$offsetStr"
addStringAnnotation("CustomUnderline", annotationData, span.start, span.end)
}
addStyle(initialSpanStyle.merge(finalSpanStyle), span.start, span.end)
val ws = themedSpanStyle.wordSpacing
if (ws.isSpecified && ws.value != 0f) {
val textToStyle = block.text.substring(span.start, span.end)
for (i in textToStyle.indices) {
if (textToStyle[i] == ' ') {
addStyle(SpanStyle(letterSpacing = ws), span.start + i, span.start + i + 1)
}
}
}
span.linkHref?.let { linkHref ->
addStringAnnotation("URL", linkHref, span.start, span.end)
}
span.elementId?.let { elementId ->
addStringAnnotation("ID", 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,
fontFamilyMap: Map
): 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 !in 1..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 "• "
}
}
}