Updated answer for 2024
I'm keeping the original answer below since it might still be useful for some people, but I recently found a library that takes care of all the parsing and converting for you: Compose Rich Editor.
This library is multiplatform and supports JVM, Android, iOS (x64, arm64, arm64 simulator), JS, and WasmJS at the time of writing.
Implement it in your common build.gradle.kts
:
...
kotlin {
...
sourceSets {
val commonMain by getting {
...
implementation("com.mohamedrejeb.richeditor:richeditor-compose:1.0.0-rc04")
}
...
}
...
}
...
Make a simple Composable function to convert an HTML string to an AnnotatedString:
@Composable
fun String.toRichHtmlString(): AnnotatedString {
val state = rememberRichTextState()
LaunchedEffect(this) {
state.setHtml(this@toRichHtmlString)
}
return state.annotatedString
}
Then call it when passing an HTML string to a Text component:
Text(text = someHtmlString.toRichHtmlString())
Original Answer
Since I'm using a Kotlin Multiplatform project with Android Jetpack Compose and JetBrains Compose for Desktop, I don't really have the option of just falling back to Android's TextView.
So I took inspiration from turbohenoch's answer and did my best to expand it to be able to interpret multiple (possibly nested) HTML formatting tags.
The code can definitely be improved, and it's not at all robust to HTML errors, but I did test it with text that contained <u>
and <b>
tags and it works fine for that at least.
Here's the code:
/**
* The tags to interpret. Add tags here and in [tagToStyle].
*/
private val tags = linkedMapOf(
"<b>" to "</b>",
"<i>" to "</i>",
"<u>" to "</u>"
)
/**
* The main entry point. Call this on a String and use the result in a Text.
*/
fun String.parseHtml(): AnnotatedString {
val newlineReplace = this.replace("<br>", "\n")
return buildAnnotatedString {
recurse(newlineReplace, this)
}
}
/**
* Recurses through the given HTML String to convert it to an AnnotatedString.
*
* @param string the String to examine.
* @param to the AnnotatedString to append to.
*/
private fun recurse(string: String, to: AnnotatedString.Builder) {
//Find the opening tag that the given String starts with, if any.
val startTag = tags.keys.find { string.startsWith(it) }
//Find the closing tag that the given String starts with, if any.
val endTag = tags.values.find { string.startsWith(it) }
when {
//If the String starts with a closing tag, then pop the latest-applied
//SpanStyle and continue recursing.
tags.any { string.startsWith(it.value) } -> {
to.pop()
recurse(string.removeRange(0, endTag!!.length), to)
}
//If the String starts with an opening tag, apply the appropriate
//SpanStyle and continue recursing.
tags.any { string.startsWith(it.key) } -> {
to.pushStyle(tagToStyle(startTag!!))
recurse(string.removeRange(0, startTag.length), to)
}
//If the String doesn't start with an opening or closing tag, but does contain either,
//find the lowest index (that isn't -1/not found) for either an opening or closing tag.
//Append the text normally up until that lowest index, and then recurse starting from that index.
tags.any { string.contains(it.key) || string.contains(it.value) } -> {
val firstStart = tags.keys.map { string.indexOf(it) }.filterNot { it == -1 }.minOrNull() ?: -1
val firstEnd = tags.values.map { string.indexOf(it) }.filterNot { it == -1 }.minOrNull() ?: -1
val first = when {
firstStart == -1 -> firstEnd
firstEnd == -1 -> firstStart
else -> min(firstStart, firstEnd)
}
to.append(string.substring(0, first))
recurse(string.removeRange(0, first), to)
}
//There weren't any supported tags found in the text. Just append it all normally.
else -> {
to.append(string)
}
}
}
/**
* Get a [SpanStyle] for a given (opening) tag.
* Add your own tag styling here by adding its opening tag to
* the when clause and then instantiating the appropriate [SpanStyle].
*
* @return a [SpanStyle] for the given tag.
*/
private fun tagToStyle(tag: String): SpanStyle {
return when (tag) {
"<b>" -> {
SpanStyle(fontWeight = FontWeight.Bold)
}
"<i>" -> {
SpanStyle(fontStyle = FontStyle.Italic)
}
"<u>" -> {
SpanStyle(textDecoration = TextDecoration.Underline)
}
//This should only throw if you add a tag to the [tags] Map and forget to add it
//to this function.
else -> throw IllegalArgumentException("Tag $tag is not valid.")
}
}
I did my best to make clear comments, but here's a quick explanation. The tags
variable is a map of the tags to track, with the keys being the opening tags and the values being their corresponding closing tags. Anything here needs to also be handled in the tagToStyle()
function, so that the code can get a proper SpanStyle for each tag.
It then recursively scans the input String, looking for tracked opening and closing tags.
If the String it's given starts with a closing tag, it'll pop the most recently-applied SpanStyle (removing it from text appended from then on) and call the recursive function on the String with that tag removed.
If the String it's given starts with an opening tag, it'll push the corresponding SpanStyle (using tagToStyle()
) and then call the recursive function on the String with that tag removed.
If the String it's given doesn't start with either a closing or opening tag, but does contain at least one of either, it'll find the first occurrence of any tracked tag (opening or closing), normally append all text in the given String up until that index, and then call the recursive function on the String starting at the index of the first tracked tag it finds.
If the String it's given doesn't have any tags, it'll just append normally, without adding or removing any styling.
Since I'm using this in an app being actively developed, I'll probably continue to update it as needed. Assuming nothing drastic changes, the latest version should be available on its GitHub repository.
AnnotatedString
. AFAIK, currently there are no HTML ->AnnotatedString
converters orSpanned
->AnnotatedString
converters. There are a couple of Markdown ->AnnotatedString
converters, but that is unlikely to help in this particular case. You may need to create a suitable converter yourself. – Esoterica