/* * 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 "• " } } }