/* * 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 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.isSpecified import timber.log.Timber 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) } 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("(? 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 } } } val bgLuminance = themeBackground.luminance() val colorLuminance = color.luminance() val l1 = maxOf(bgLuminance, colorLuminance) val l2 = minOf(bgLuminance, colorLuminance) val contrast = (l1 + 0.05f) / (l2 + 0.05f) if (isBackground) { return if (isDarkTheme && colorLuminance > 0.5f) { Color.Transparent } else if (!isDarkTheme && colorLuminance < 0.2f) { Color.Transparent } else { color } } else { if (contrast >= 4.5f) { return color } val hsl = FloatArray(3) androidx.core.graphics.ColorUtils.colorToHSL(color.toArgb(), hsl) if (bgLuminance < 0.5f) { hsl[2] = hsl[2].coerceAtLeast(0.7f) } else { hsl[2] = hsl[2].coerceAtMost(0.3f) } return Color(androidx.core.graphics.ColorUtils.HSLToColor(hsl)) } } private fun splitDeclarations(declarations: String): List { val parts = declarations.split(';').toMutableList() if (parts.size <= 1) return parts val result = mutableListOf() 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, themeBackgroundColor: Color = Color.Unspecified, themeTextColor: Color = Color.Unspecified ): OptimizedCssParseResult { val byTag = mutableMapOf>() val byClass = mutableMapOf>() val byId = mutableMapOf>() val otherComplex = mutableListOf() val fontFaces = mutableListOf() 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, themeBackgroundColor, themeTextColor ) val importantStyle = parseProperties( propertiesGroup, baseFontSizeSp, density, constraints, onlyImportant = true, isDarkTheme, themeBackgroundColor, themeTextColor ) 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, themeBackgroundColor: Color = Color.Unspecified, themeTextColor: Color = Color.Unspecified ): 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 = 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 var wordSpacing: TextUnit = TextUnit.Unspecified var textDecorationStyle: String? = null var textDecorationColor: Color = Color.Unspecified var textUnderlineOffset: Dp = Dp.Unspecified var borderTopWidth: Dp? = null var borderRightWidth: Dp? = null var borderBottomWidth: Dp? = null var borderLeftWidth: Dp? = null var borderTopStyle: String? = null var borderRightStyle: String? = null var borderBottomStyle: String? = null var borderLeftStyle: String? = null var borderTopColor: Color? = null var borderRightColor: Color? = null var borderBottomColor: Color? = null var borderLeftColor: Color? = null var borderTopLeftRadius: Dp = 0.dp var borderTopRightRadius: Dp = 0.dp var borderBottomRightRadius: Dp = 0.dp var borderBottomLeftRadius: Dp = 0.dp 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 } 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, themeBackgroundColor, themeTextColor) } 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 (parsedColor != null) finalBorderColor = parsedColor if (styleStr != null) finalBorderStyle = styleStr } else if (!isExplicitWidth) { if (parsedColor != null) finalBorderColor = parsedColor if (styleStr != null) finalBorderStyle = styleStr } } when (key) { "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, themeBackgroundColor, themeTextColor)) } } "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" -> { val parts = value.split(" ") val decos = mutableListOf() if (parts.contains("underline")) decos.add(TextDecoration.Underline) if (parts.contains("line-through")) decos.add(TextDecoration.LineThrough) if (parts.contains("none")) { spanStyle = spanStyle.copy(textDecoration = TextDecoration.None) } else if (decos.isNotEmpty()) { spanStyle = spanStyle.copy(textDecoration = TextDecoration.combine(decos)) } val styles = listOf("solid", "double", "dotted", "dashed", "wavy") parts.firstOrNull { it in styles }?.let { textDecorationStyle = it } parts.firstNotNullOfOrNull { parseColor(it) }?.let { color -> textDecorationColor = this@CssParser.adaptColorForTheme(color, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor) } } "word-spacing" -> { val trimmedValue = value.trim() wordSpacing = if (trimmedValue.lowercase() == "normal") { TextUnit.Unspecified } else { parseCssDimensionToTextUnit(value, containerWidthPx, density) } } "text-decoration-style" -> { textDecorationStyle = value } "text-decoration-color" -> { parseColor(value)?.let { textDecorationColor = this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor) } } "text-underline-offset" -> { textUnderlineOffset = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) } "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 = parseCssDimension(value, baseFontSizeSp, density, containerWidthPx) "max-width" -> maxWidth = parseCssDimension(value, baseFontSizeSp, density, containerWidthPx) "height" -> height = parseCssDimension(value, baseFontSizeSp, density, containerWidthPx) "background-color" -> { val originalColor = parseColor(value) ?: Color.Unspecified backgroundColor = this@CssParser.adaptColorForTheme(originalColor, isDarkTheme, isBackground = true, themeBackgroundColor, themeTextColor) } // Border Properties "border-width" -> { val widths = parseShorthand4(value, baseFontSizeSp, density, containerWidthPx) borderTopWidth = widths[0]; borderRightWidth = widths[1]; borderBottomWidth = widths[2]; borderLeftWidth = widths[3] } "border-style" -> { val styles = parseShorthand4Strings(value) borderTopStyle = styles[0]; borderRightStyle = styles[1]; borderBottomStyle = styles[2]; borderLeftStyle = styles[3] } "border-color" -> { val colors = parseShorthand4Colors(value) borderTopColor = colors[0]; borderRightColor = colors[1]; borderBottomColor = colors[2]; borderLeftColor = colors[3] } "border-top-width" -> borderTopWidth = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) "border-right-width" -> borderRightWidth = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) "border-bottom-width" -> borderBottomWidth = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) "border-left-width" -> borderLeftWidth = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) "border-top-style" -> borderTopStyle = value "border-right-style" -> borderRightStyle = value "border-bottom-style" -> borderBottomStyle = value "border-left-style" -> borderLeftStyle = value "border-top-color" -> borderTopColor = parseColor(value) "border-right-color" -> borderRightColor = parseColor(value) "border-bottom-color" -> borderBottomColor = parseColor(value) "border-left-color" -> borderLeftColor = parseColor(value) "border-top" -> { val (w, s, c) = parseBorderShorthand(value, baseFontSizeSp, density, containerWidthPx) if (w != null) borderTopWidth = w if (s != null) borderTopStyle = s if (c != null) borderTopColor = c } "border-right" -> { val (w, s, c) = parseBorderShorthand(value, baseFontSizeSp, density, containerWidthPx) if (w != null) borderRightWidth = w if (s != null) borderRightStyle = s if (c != null) borderRightColor = c } "border-bottom" -> { val (w, s, c) = parseBorderShorthand(value, baseFontSizeSp, density, containerWidthPx) if (w != null) borderBottomWidth = w if (s != null) borderBottomStyle = s if (c != null) borderBottomColor = c } "border-left" -> { val (w, s, c) = parseBorderShorthand(value, baseFontSizeSp, density, containerWidthPx) if (w != null) borderLeftWidth = w if (s != null) borderLeftStyle = s if (c != null) borderLeftColor = c } "border" -> { val (w, s, c) = parseBorderShorthand(value, baseFontSizeSp, density, containerWidthPx) if (w != null) { borderTopWidth = w; borderRightWidth = w; borderBottomWidth = w; borderLeftWidth = w } if (s != null) { borderTopStyle = s; borderRightStyle = s; borderBottomStyle = s; borderLeftStyle = s } if (c != null) { borderTopColor = c; borderRightColor = c; borderBottomColor = c; borderLeftColor = c } } "border-radius" -> { val radii = parseShorthand4(value, baseFontSizeSp, density, containerWidthPx) borderTopLeftRadius = radii[0] borderTopRightRadius = radii[1] borderBottomRightRadius = radii[2] borderBottomLeftRadius = radii[3] } "border-top-left-radius" -> borderTopLeftRadius = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) "border-top-right-radius" -> borderTopRightRadius = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) "border-bottom-right-radius" -> borderBottomRightRadius = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) "border-bottom-left-radius" -> borderBottomLeftRadius = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) "border-collapse" -> { if (value in listOf("collapse", "separate")) { borderCollapse = value } } "border-spacing" -> { borderSpacing = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) } "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, themeBackgroundColor, themeTextColor) } } "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 } 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 fun makeBorder(width: Dp?, style: String?, color: Color?): BorderStyle? { val finalWidth = width ?: if (style != null && style != "none" && style != "hidden") 3.dp else 0.dp val finalStyle = style ?: "none" val finalColor = color ?: spanStyle.color.takeIf { it.isSpecified } ?: Color.Black val adaptedColor = this@CssParser.adaptColorForTheme(finalColor, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor) if (finalWidth > 0.dp && finalStyle != "none" && finalStyle != "hidden") { return BorderStyle(finalWidth, adaptedColor, finalStyle) } return null } val finalBorderTop = makeBorder(borderTopWidth, borderTopStyle, borderTopColor) val finalBorderRight = makeBorder(borderRightWidth, borderRightStyle, borderRightColor) val finalBorderBottom = makeBorder(borderBottomWidth, borderBottomStyle, borderBottomColor) val finalBorderLeft = makeBorder(borderLeftWidth, borderLeftStyle, borderLeftColor) val blockStyle = BlockStyle( margin = margin, padding = padding, width = width, maxWidth = maxWidth, height = height, backgroundColor = backgroundColor, borderTop = finalBorderTop, borderRight = finalBorderRight, borderBottom = finalBorderBottom, borderLeft = finalBorderLeft, borderTopLeftRadius = borderTopLeftRadius, borderTopRightRadius = borderTopRightRadius, borderBottomRightRadius = borderBottomRightRadius, borderBottomLeftRadius = borderBottomLeftRadius, 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 ) return CssStyle( spanStyle, paragraphStyle, blockStyle, fontFamilies, display, fontSize, textTransform, boxSizing, content, hyphens, fontVariantNumeric, textEmphasis, wordSpacing, textDecorationStyle, textDecorationColor, textUnderlineOffset ) } private fun parseShorthand4(value: String, baseFontSize: Float, density: Float, containerWidth: Int): List { val parts = value.split(' ').filter { it.isNotBlank() } val dps = parts.map { parseCssSizeToDp(it, baseFontSize, density, containerWidth) } return when (dps.size) { 1 -> listOf(dps[0], dps[0], dps[0], dps[0]) 2 -> listOf(dps[0], dps[1], dps[0], dps[1]) // Top/Bottom, Left/Right 3 -> listOf(dps[0], dps[1], dps[2], dps[1]) // Top, Left/Right, Bottom 4 -> listOf(dps[0], dps[1], dps[2], dps[3]) // Top, Right, Bottom, Left else -> listOf(0.dp, 0.dp, 0.dp, 0.dp) } } private fun parseShorthand4Strings(value: String): List { val parts = value.split(' ').filter { it.isNotBlank() } return when (parts.size) { 1 -> listOf(parts[0], parts[0], parts[0], parts[0]) 2 -> listOf(parts[0], parts[1], parts[0], parts[1]) 3 -> listOf(parts[0], parts[1], parts[2], parts[1]) 4 -> listOf(parts[0], parts[1], parts[2], parts[3]) else -> listOf(null, null, null, null) } } private fun parseShorthand4Colors(value: String): List { if (value.contains("(") || value.contains(",")) { val c = parseColor(value) return listOf(c, c, c, c) } val parts = value.split(' ').filter { it.isNotBlank() } val colors = parts.map { parseColor(it) } return when (colors.size) { 1 -> listOf(colors[0], colors[0], colors[0], colors[0]) 2 -> listOf(colors[0], colors[1], colors[0], colors[1]) 3 -> listOf(colors[0], colors[1], colors[2], colors[1]) 4 -> listOf(colors[0], colors[1], colors[2], colors[3]) else -> listOf(null, null, null, null) } } private fun parseBorderShorthand(value: String, baseFontSize: Float, density: Float, containerWidth: Int): Triple { val parts = value.split(" ").filter { it.isNotBlank() } var w: Dp? = null var s: String? = null var c: Color? = null val keywords = mapOf("thin" to 1.dp, "medium" to 3.dp, "thick" to 5.dp) parts.forEach { part -> val lower = part.lowercase() if (keywords.containsKey(lower)) { w = keywords[lower] } else if (lower.endsWith("px") || lower.endsWith("em") || lower.endsWith("rem") || lower.endsWith("%") || lower.first().isDigit()) { val parsed = parseCssSizeToDp(part, baseFontSize, density, containerWidth) if (parsed > 0.dp || part == "0") w = parsed } else if (lower in listOf("solid", "dashed", "dotted", "double", "none", "hidden", "groove", "ridge", "inset", "outset")) { s = lower } else { val parsedColor = parseColor(part) if (parsedColor != null) c = parsedColor } } return Triple(w, s, c) } internal fun parseCssDimension( size: String, baseFontSizeSp: Float, density: Float, containerWidthPx: Int ): Dp { val trimmed = size.trim().lowercase() if (trimmed in listOf("auto", "none", "max-content", "min-content", "fit-content", "inherit", "initial")) { return Dp.Unspecified } if (trimmed == "0" || trimmed == "0px") return 0.dp return when { trimmed.endsWith("px") -> trimmed.removeSuffix("px").toFloatOrNull()?.let { (it / density).dp } ?: Dp.Unspecified trimmed.endsWith("dp") -> trimmed.removeSuffix("dp").toFloatOrNull()?.dp ?: Dp.Unspecified trimmed.endsWith("em") -> trimmed.removeSuffix("em").toFloatOrNull()?.let { (it * baseFontSizeSp).dp } ?: Dp.Unspecified trimmed.endsWith("rem") -> trimmed.removeSuffix("rem").toFloatOrNull()?.let { (it * baseFontSizeSp).dp } ?: Dp.Unspecified trimmed.endsWith("pt") -> trimmed.removeSuffix("pt").toFloatOrNull()?.let { (it * 1.33f).dp } ?: Dp.Unspecified trimmed.endsWith("%") -> { val percent = trimmed.removeSuffix("%").toFloatOrNull() if (percent != null) { ((percent / 100f) * containerWidthPx / density).dp } else { Dp.Unspecified } } trimmed.endsWith("vw") -> { val percent = trimmed.removeSuffix("vw").toFloatOrNull() if (percent != null) { ((percent / 100f) * containerWidthPx / density).dp } else { Dp.Unspecified } } trimmed.endsWith("vh") -> Dp.Unspecified trimmed.toFloatOrNull() != null -> (trimmed.toFloat() / density).dp else -> Dp.Unspecified } } internal fun parseCssSizeToDp( size: String, baseFontSizeSp: Float, density: Float, containerWidthPx: Int ): Dp { val trimmed = size.trim().lowercase() BORDER_WIDTH_KEYWORDS[trimmed]?.let { return it } val dim = parseCssDimension(size, baseFontSizeSp, density, containerWidthPx) return if (dim.isSpecified) dim else 0.dp } 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() } } }