book-reader/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/CssParser.kt
Aryan d7a9cae9e1
v1.0.47 (#279)
* Add performance and stylus debugging logs

* Refactor and decouple UI models from `MainViewModel`

* Refactor library state management and projection logic

* Implement desktop shell using Compose Multiplatform

* Implement desktop shell using Compose Multiplatform

* Implement desktop shell using Compose Multiplatform

* Introduce ReaderEngine and enhance EPUB reader features in windows app

* Move core paginated reader logic to a Kotlin Multiplatform `shared` module and introduce experimental desktop support.

* Implement PDF rendering and text extraction for desktop using Pdfium

* Add `NonReaderScreens.kt` and UI dependencies

* Refactor and centralize library state management and models to improve cross-platform consistency

* Implement JSON persistence for desktop library and enhance library management features including shelf CRUD, tagging, and metadata editing

* Implement PDF annotation system and enhanced zoom controls for the desktop viewer

* Implement WebView-based EPUB rendering for desktop using CEF and embedded resources

* Optimize UI state projection, navigation state handling, and main screen pager performance

* Implement Bring Your Own Key (BYOK) support for AI features in OSS version

* Support Gemini-based Cloud TTS with BYOK support for OSS builds

* Refactor table cell image sizing in `PaginatedReader` and improve `MobiParser` native library loading and error handling.

* crash fixes

* Enhance navigation stability with lifecycle-aware safety checks and update `navigation-compose` to 2.9.6

* Implement dynamic bottom padding for the page info bar to account for device rounded corners

* Implement bidirectional jump history navigation and replace the jump-back pill with a dedicated `PdfJumpHistoryBar`

* Optimize PDF tiling performance and refine pan-and-fling gesture handling

* Implement customizable toolbars with drag-and-drop reordering and placement for PDF and EPUB readers

* Updated UI for customize toolbar

* Refine drag-and-drop reordering and section assignment for PDF and EPUB reader controls

* restructure PDF viewer UI component hierarchy to fix verifier crash

* Implement separate text dimming factors for light and dark themes

* Synchronize Pdfium access and improve resource lifecycle safety across Kotlin and native layers

* Enhance image alignment in paginated and EPUB readers through anchor detection and style-based positioning

* Centralize file type resolution logic and implement HTML sanitization during import

* Introduce vertical margin customization and configurable progress bar positioning

* texture support in epub reader

* Enhance TTS session management, progress tracking, and diagnostic logging

* Optimize library state projection and folder synchronization performance by refactoring collection lookups and refining metadata extraction logic.

* Refine TTS page mapping for PDF and overhaul TTS control UI

* Implement natural session completion logic in `TtsPlaybackManager` for cloud tts

* Replace Snackbar with `CustomTopBanner` for notifications in `PdfViewerScreen`

* Refine TTS playback continuity across PDF pages and improve state management for session transitions

* Implement global texture transparency and enhance textured theme support across PDF and EPUB readers.

* Update reader themes and improve texture rendering in page animations, EPUB UI, and immersive mode

* Add Support Project screen

* Optimize library performance via projection caching, batch database updates, and scoped folder synchronization.

* Enhance folder synchronization with fallback query mechanisms and refactor annotation sidecar importing logic

* Bump version to 1.0.47 (51)
2026-05-04 21:55:38 +05:30

1098 lines
52 KiB
Kotlin

/*
* 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 <https://www.gnu.org/licenses/>.
*
* 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.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 kotlin.math.roundToInt
private const val IMPORTANT_SPECIFICITY_BOOST = 10_000
private object ReaderCssLog {
fun d(@Suppress("UNUSED_PARAMETER") message: String) = Unit
fun w(@Suppress("UNUSED_PARAMETER") message: String) = Unit
fun w(@Suppress("UNUSED_PARAMETER") throwable: Throwable, @Suppress("UNUSED_PARAMETER") message: String) = Unit
fun e(@Suppress("UNUSED_PARAMETER") throwable: Throwable, @Suppress("UNUSED_PARAMETER") message: String) = Unit
}
private fun Color.luminance(): Float {
if (!this.isSpecified) return 0f
return (0.299f * red + 0.587f * green + 0.114f * blue)
}
private fun resolveCssRelativePath(cssPath: String, rawSrc: String): String {
if (rawSrc.startsWith("/") || rawSrc.contains("://")) return rawSrc
val normalizedBase = cssPath.replace('\\', '/').substringBeforeLast('/', "")
val parts = ArrayDeque<String>()
(if (normalizedBase.isBlank()) rawSrc else "$normalizedBase/$rawSrc")
.replace('\\', '/')
.split('/')
.forEach { part ->
when (part) {
"", "." -> Unit
".." -> if (parts.isNotEmpty()) parts.removeLast()
else -> parts.addLast(part)
}
}
return parts.joinToString("/")
}
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_REGEX = Regex("#[^\\s,]+")
private val CLASS_ATTRIBUTE_SELECTOR_REGEX = Regex("\\.[^\\s,]+|\\[[^]]+]|:(?!:)[^\\s,]+")
private val TYPE_PSEUDO_ELEMENT_SELECTOR_REGEX = Regex("(?<![.#\\[])\\b[a-zA-Z-]+|::[a-zA-Z-]+")
private data class FontSource(val url: String, val format: String?)
// Regex to identify simple, single-part selectors for fast categorization
private val SIMPLE_TAG_SELECTOR = Regex("^[a-zA-Z0-9]+$")
private val SIMPLE_CLASS_SELECTOR = Regex("^\\.[a-zA-Z0-9_-]+$")
private val SIMPLE_ID_SELECTOR = Regex("^#[a-zA-Z0-9_-]+$")
private val BORDER_WIDTH_KEYWORDS = mapOf(
"thin" to 1.dp,
"medium" to 3.dp,
"thick" to 5.dp
)
fun adaptColorForTheme(
color: Color,
isDarkTheme: Boolean,
isBackground: Boolean,
themeBackground: Color = Color.Unspecified,
themeText: Color = Color.Unspecified
): Color {
if (!color.isSpecified) return color
if (color.alpha < 0.9f && color != Color.Transparent) return color
if (color == Color.Transparent) return color
if (!themeBackground.isSpecified || !themeText.isSpecified) {
val luminance = color.luminance()
return if (isDarkTheme) {
if (isBackground) {
if (luminance > 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
}
return themeText.takeIf { it.isSpecified } ?: color
}
}
private fun splitDeclarations(declarations: String): List<String> {
val parts = declarations.split(';').toMutableList()
if (parts.size <= 1) return parts
val result = mutableListOf<String>()
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) {
ReaderCssLog.d("Reassembled declaration. Original: '$originalCurrent'. Final: '$current'")
}
result.add(current)
}
return result
}
private fun calculateSpecificity(selector: String): Int {
val ids = ID_SELECTOR_REGEX.findAll(selector).count()
val classesAndAttributes = CLASS_ATTRIBUTE_SELECTOR_REGEX.findAll(selector).count()
val elementsAndPseudos = TYPE_PSEUDO_ELEMENT_SELECTOR_REGEX.findAll(selector).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<String, MutableList<CssRule>>()
val byClass = mutableMapOf<String, MutableList<CssRule>>()
val byId = mutableMapOf<String, MutableList<CssRule>>()
val otherComplex = mutableListOf<CssRule>()
val fontFaces = mutableListOf<FontFaceInfo>()
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, "")
ReaderCssLog.d("CssParser: Checking for @font-face rules...")
val fontFaceMatches = FONT_FACE_REGEX.findAll(cleanedCss)
if (!fontFaceMatches.any()) {
ReaderCssLog.d("CssParser: No @font-face rules found by regex.")
}
fontFaceMatches.forEach { match ->
ReaderCssLog.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"]
ReaderCssLog.d("Parsing font-face for family: $fontFamily. Raw src string: $srcString")
if (fontFamily == null || srcString == null) {
ReaderCssLog.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()
ReaderCssLog.d("Processing src part: '$trimmedPart'")
urlWithFormatRegex.find(trimmedPart)?.let {
ReaderCssLog.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]
ReaderCssLog.d("Matched url() only. URL: '$url'")
val format = when {
url.startsWith("data:", ignoreCase = true) -> {
val mediaType = url.substringAfter("data:").substringBefore(';')
ReaderCssLog.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 -> {
ReaderCssLog.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 -> {
ReaderCssLog.w("Could not determine format from URL: $url")
null
}
}
ReaderCssLog.d("Determined format: '$format'")
if (format != null) {
FontSource(url = url, format = format)
} else {
null
}
}
}
if (sources.isEmpty()) {
ReaderCssLog.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
ReaderCssLog.d("Selected font source for '$fontFamily': '${preferredSource.url}' with format '${preferredSource.format}'")
val finalSrc = if (cssPath != null && !rawSrc.startsWith("data:")) {
try {
resolveCssRelativePath(cssPath, rawSrc)
} catch (e: Exception) {
ReaderCssLog.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<String> = 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<TextDecoration>()
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<Dp> {
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<String?> {
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<Color?> {
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<Dp?, String?, Color?> {
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((r * 17).toInt(), (g * 17).toInt(), (b * 17).toInt(), 255)
}
6 -> Color(
((colorLong shr 16) and 0xFF).toInt(),
((colorLong shr 8) and 0xFF).toInt(),
(colorLong and 0xFF).toInt(),
255
) // #RRGGBB
8 -> Color(
((colorLong shr 16) and 0xFF).toInt(),
((colorLong shr 8) and 0xFF).toInt(),
(colorLong and 0xFF).toInt(),
((colorLong shr 24) and 0xFF).toInt()
) // #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()
}
}
}