* 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)
1098 lines
52 KiB
Kotlin
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()
|
|
}
|
|
}
|
|
}
|