Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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<String>): 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<String>): 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<String>): 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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -57,4 +60,54 @@ class HtmlConverterTest {

assertEquals(expected, htmlToAnnotatedString(html))
}

@Test
fun textColorTest() {
// language=html
val html = """
<span style="color:red;">Open<b>Fr<i style="color:#001F00 ;">e</i>e</b>Map</span>
""".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)
}

@Test
fun textColorFunctionsTest() {
// language=html
val html = """
<span style="color: hsl(0, 100%, 50%);">Open<b style="color: hwb(195 0% 0%)">Fr<i style="color: rgb(255, 0, 255, 0.6) ;">e</i>e</b>Map</span>
""".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)
}
}