book-reader/app/src/main/java/com/aryan/reader/paginatedreader/ContentStyler.kt

511 lines
No EOL
22 KiB
Kotlin

/*
* 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 <https://www.gnu.org/licenses/>.
*
* 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<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 ""
}
}
}