From 5b5487efc541b96ca60e74770f81b29058296b89 Mon Sep 17 00:00:00 2001 From: Alexandre Conte Date: Fri, 18 Jul 2025 22:31:38 +0200 Subject: [PATCH 1/2] Add isTextColor test --- .../htmlconverter/HtmlConverterTest.kt | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/htmlconverter/src/commonTest/kotlin/be/digitalia/compose/htmlconverter/HtmlConverterTest.kt b/htmlconverter/src/commonTest/kotlin/be/digitalia/compose/htmlconverter/HtmlConverterTest.kt index e4406ee..8e7043f 100644 --- a/htmlconverter/src/commonTest/kotlin/be/digitalia/compose/htmlconverter/HtmlConverterTest.kt +++ b/htmlconverter/src/commonTest/kotlin/be/digitalia/compose/htmlconverter/HtmlConverterTest.kt @@ -1,9 +1,12 @@ package be.digitalia.compose.htmlconverter +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import kotlin.test.Test import kotlin.test.assertEquals @@ -57,4 +60,29 @@ class HtmlConverterTest { assertEquals(expected, htmlToAnnotatedString(html)) } + + @Test + fun textColorTest() { + // language=html + val html = """ + OpenFreeMap + """.trimIndent().trim() + + val expected = buildAnnotatedString { + pushStyle(SpanStyle(color = Color(255, 0, 0))) + append("Open") + pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) + append("Fr") + pushStyle(SpanStyle(color = Color(0, 31, 0), fontStyle = FontStyle.Italic)) + append("e") + pop() + append("e") + pop() + append("Map") + pop() + } + + val actual = htmlToAnnotatedString(html, style = HtmlStyle(isTextColorEnabled = true)) + assertEquals(expected, actual) + } } From 3940837e84fa08e5ca0ae6e4f2e971399215ab85 Mon Sep 17 00:00:00 2001 From: Alexandre Conte Date: Sat, 19 Jul 2025 18:09:58 +0200 Subject: [PATCH 2/2] Add sRgb color functions It implements decoding of functions: - rgb, rgba - hsl, hsla - hwb --- README.md | 2 +- .../htmlconverter/internal/HtmlColors.kt | 98 ++++++++++++++++++- .../htmlconverter/HtmlConverterTest.kt | 25 +++++ 3 files changed, 120 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 3f470a4..030ab4f 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ Text(text = convertedText) - Text coloring is available for supported HTML **inline** tags only, through the CSS `"style"` attribute: `span`, `strong`, `b`, `em`, `cite`, `dfn`, `i`, `big`, `small`, `tt`, `code`, `a`, `u`, `del`, `s`, `strike`, `sup`, `sub`. - Both foreground (`color`) and background (`background`, `background-color`) CSS properties are supported. - All [CSS level 4 named colors](https://www.w3.org/TR/css-color-4/#named-colors) (case insensitive) and all hexadecimal RGB color definitions (`#RRGGBB`, `#RRGGBBAA`, `#RGB`, `#RGBA`) are supported. -- Other color formats like `rgb(...)`, `rgba(...)`, `hsl(...)` and `hsla(...)` are **not** supported. +- Other sRGB color formats like `rgb(...)`, `rgba(...)`, `hsl(...)` and `hsla(...)` are supported, contrary to newer ones (e.g. `lab(...)`, `oklch(...)`). - If a CSS color property is defined on a hyperlink or on a tag inside a hyperlink, it will override any color defined in `textLinkStyles`. Example of supported CSS color styling: diff --git a/htmlconverter/src/commonMain/kotlin/be/digitalia/compose/htmlconverter/internal/HtmlColors.kt b/htmlconverter/src/commonMain/kotlin/be/digitalia/compose/htmlconverter/internal/HtmlColors.kt index 17b0aac..356aae3 100644 --- a/htmlconverter/src/commonMain/kotlin/be/digitalia/compose/htmlconverter/internal/HtmlColors.kt +++ b/htmlconverter/src/commonMain/kotlin/be/digitalia/compose/htmlconverter/internal/HtmlColors.kt @@ -1,6 +1,7 @@ package be.digitalia.compose.htmlconverter.internal import androidx.compose.ui.graphics.Color +import kotlin.math.roundToInt private val COLOR_NAMES = listOf( "AliceBlue", @@ -385,31 +386,120 @@ internal fun getHtmlColor(value: String): Color { return Color.Unspecified } val firstChar = value[0] + val lastChar = value[value.lastIndex] return when { firstChar == '#' -> value.hexToColor() + lastChar == ')' -> getColorFromFunction(value) firstChar.isLetter() -> getColorByName(value) else -> Color.Unspecified } } -private val COLOR_STYLE_REGEX = Regex("(?:^|;)\\s*color\\s*:\\s*(.*?)(?:$|\\s|;)", RegexOption.IGNORE_CASE) +// language=regexp +private const val NAME = "\\s*(\\w+)\\s*" +// language=regexp +private const val VALUE = "\\s*([\\w.%]+)\\s*" +private val COMMA_REGEX = Regex("$NAME\\($VALUE,$VALUE,$VALUE(?:,$VALUE)?\\)") +private val WS_REGEX = Regex("$NAME\\($VALUE\\s$VALUE\\s$VALUE(?:/$VALUE)?\\)") + +internal fun getColorFromFunction(color: String): Color { + val match = COMMA_REGEX.find(color) ?: WS_REGEX.find(color) + if (match == null) return Color.Unspecified + + val name = match.groupValues[1] + val values = match.groupValues.drop(2) + return when(name) { + "rgb", "rgba" -> colorFromRgb(values) + "hsl", "hsla" -> colorFromHsl(values) + "hwb" -> colorFromHwb(values) + else -> TODO("other methods not implemented") + } +} + +private fun colorFromRgb(values: List): Color { + if (values.size < 3 || values.size > 4) return Color.Unspecified + val alpha = if (values.size == 4 && values[3].isNotBlank()) getFloatFromPct(values[3]) else 1.0f + val r = getIntFromPct(values[0]) + val g = getIntFromPct(values[1]) + val b = getIntFromPct(values[2]) + val roundedAlpha = (alpha * 255).roundToInt() + return Color(r, g, b, roundedAlpha) +} + +private fun colorFromHsl(values: List): Color { + if (values.size < 3 || values.size > 4) return Color.Unspecified + val alpha = if (values.size == 4 && values[3].isNotBlank()) getFloatFromPct(values[3]) else 1.0f + val h = getDegree(values[0]) + val s = getFloatFromPct(values[1]) + val l = getFloatFromPct(values[2]) + return Color.hsl(h, s, l, alpha) +} + +private fun colorFromHwb(values: List): Color { + if (values.size < 3 || values.size > 4) return Color.Unspecified + val alpha = if (values.size == 4 && values[3].isNotBlank()) getFloatFromPct(values[3]) else 1.0f + val h = getDegree(values[0]) + val w = getFloatFromPct(values[1]) + val b = getFloatFromPct(values[2]) + return convertHwbToColor(h, w, b, alpha) +} + +private fun convertHwbToColor(h: Float, w: Float, b: Float, alpha: Float): Color { + fun componentConvertHslToHwb(hsl: Float) = hsl * (1 - w - b) + w + if(w + b >= 1) { + val gray = w / (w + b) + return Color(gray, gray, gray, alpha) + } + val color = Color.hsl(h, 1f, 0.5f) + val red = componentConvertHslToHwb(color.red) + val green = componentConvertHslToHwb(color.green) + val blue = componentConvertHslToHwb(color.blue) + return Color(red, green, blue, alpha) +} + +private fun getFloatFromPct(s: String): Float { + return if (s.last() == '%') + s.slice(0..s.lastIndex-1).toFloat() / 100 + else s.toFloat() +} + +private fun getIntFromPct(s: String): Int { + return if (s.last() == '%') + (s.slice(0..s.lastIndex-1).toFloat() * 255).roundToInt() + else s.toInt() +} + +private val DEGREE_REGEX = Regex("\\s*([\\d.-]+)([a-z]*)") + +private fun getDegree(s: String): Float { + val matchResult = DEGREE_REGEX.find(s) + if (matchResult== null) + error("$s is not a proper pct") + val value = matchResult.groupValues[1].toFloat() + val type = matchResult.groupValues[2] + return if (type.isEmpty() || type == "deg") + value + else + value * 360 +} +private val COLOR_STYLE_REGEX = Regex("(?:^|;)\\s*color\\s*:\\s*(.*?)(?:$|;)", RegexOption.IGNORE_CASE) internal fun getColorFromStyle(style: String): Color { val matchResult = COLOR_STYLE_REGEX.find(style) return if (matchResult != null) { - getHtmlColor(matchResult.groupValues[1]) + getHtmlColor(matchResult.groupValues[1].trimEnd()) } else { Color.Unspecified } } private val BACKGROUND_COLOR_STYLE_REGEX = - Regex("(?:^|;)\\s*background(?:-color)?\\s*:\\s*(.*?)(?:$|\\s|;)", RegexOption.IGNORE_CASE) + Regex("(?:^|;)\\s*background(?:-color)?\\s*:\\s*(.*?)(?:$|;)", RegexOption.IGNORE_CASE) internal fun getBackgroundColorFromStyle(style: String): Color { val matchResult = BACKGROUND_COLOR_STYLE_REGEX.find(style) return if (matchResult != null) { - getHtmlColor(matchResult.groupValues[1]) + getHtmlColor(matchResult.groupValues[1].trimEnd()) } else { Color.Unspecified } diff --git a/htmlconverter/src/commonTest/kotlin/be/digitalia/compose/htmlconverter/HtmlConverterTest.kt b/htmlconverter/src/commonTest/kotlin/be/digitalia/compose/htmlconverter/HtmlConverterTest.kt index 8e7043f..f3e6dd7 100644 --- a/htmlconverter/src/commonTest/kotlin/be/digitalia/compose/htmlconverter/HtmlConverterTest.kt +++ b/htmlconverter/src/commonTest/kotlin/be/digitalia/compose/htmlconverter/HtmlConverterTest.kt @@ -85,4 +85,29 @@ class HtmlConverterTest { val actual = htmlToAnnotatedString(html, style = HtmlStyle(isTextColorEnabled = true)) assertEquals(expected, actual) } + + @Test + fun textColorFunctionsTest() { + // language=html + val html = """ + OpenFreeMap + """.trimIndent().trim() + + val expected = buildAnnotatedString { + pushStyle(SpanStyle(color = Color(255, 0, 0))) + append("Open") + pushStyle(SpanStyle(color = Color(0, 191, 255), fontWeight = FontWeight.Bold)) + append("Fr") + pushStyle(SpanStyle(color = Color(255, 0, 255, 153), fontStyle = FontStyle.Italic)) + append("e") + pop() + append("e") + pop() + append("Map") + pop() + } + + val actual = htmlToAnnotatedString(html, style = HtmlStyle(isTextColorEnabled = true)) + assertEquals(expected, actual) + } }