/* * Episteme Reader - A native Android document reader. * Copyright (C) 2026 Episteme Authors * * 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 . * * Electronic 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.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, private val density: Density, private val isDarkTheme: Boolean, private val chapterAbsPath: String, private val extractionBasePath: String, private val userTextAlign: TextAlign? ) { fun style(semanticBlocks: List): List { return groupFloatingBlocks(semanticBlocks.mapNotNull { styleBlock(it) }) } // ADD this function to group floating blocks, similar to the original parser 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 floatedImage = currentBlock 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( 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, 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 < 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 "• " } } }