diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/TextDemos.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/TextDemos.kt
index 006e23338127e..dc709d13a11c2 100644
--- a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/TextDemos.kt
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/TextDemos.kt
@@ -37,6 +37,7 @@ import androidx.compose.foundation.demos.text2.TextFieldReceiveContentDemo
import androidx.compose.foundation.samples.BasicTextFieldUndoSample
import androidx.compose.integration.demos.common.ComposableDemo
import androidx.compose.integration.demos.common.DemoCategory
+import androidx.compose.ui.text.samples.AnnotatedStringFromHtml
val TextDemos = DemoCategory(
"Text",
@@ -213,5 +214,6 @@ val TextDemos = DemoCategory(
)
),
ComposableDemo("Text Pointer Icon") { TextPointerIconDemo() },
+ ComposableDemo("Html") { AnnotatedStringFromHtml() }
)
)
diff --git a/compose/ui/ui-text/api/current.txt b/compose/ui/ui-text/api/current.txt
index 0cad6283573e5..af80153c291a1 100644
--- a/compose/ui/ui-text/api/current.txt
+++ b/compose/ui/ui-text/api/current.txt
@@ -117,6 +117,10 @@ package androidx.compose.ui.text {
@SuppressCompatibility @kotlin.RequiresOptIn(message="This API is experimental and is likely to change in the future.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) public @interface ExperimentalTextApi {
}
+ public final class Html_androidKt {
+ method public static androidx.compose.ui.text.AnnotatedString parseAsHtml(String);
+ }
+
@SuppressCompatibility @kotlin.RequiresOptIn(level=kotlin.RequiresOptIn.Level.ERROR, message="This is internal API that may change frequently and without warning.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.CLASS, kotlin.annotation.AnnotationTarget.FUNCTION, kotlin.annotation.AnnotationTarget.PROPERTY}) public @interface InternalTextApi {
}
diff --git a/compose/ui/ui-text/api/restricted_current.txt b/compose/ui/ui-text/api/restricted_current.txt
index 8fd548b4b8e8d..675ad44b1005b 100644
--- a/compose/ui/ui-text/api/restricted_current.txt
+++ b/compose/ui/ui-text/api/restricted_current.txt
@@ -117,6 +117,10 @@ package androidx.compose.ui.text {
@SuppressCompatibility @kotlin.RequiresOptIn(message="This API is experimental and is likely to change in the future.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) public @interface ExperimentalTextApi {
}
+ public final class Html_androidKt {
+ method public static androidx.compose.ui.text.AnnotatedString parseAsHtml(String);
+ }
+
@SuppressCompatibility @kotlin.RequiresOptIn(level=kotlin.RequiresOptIn.Level.ERROR, message="This is internal API that may change frequently and without warning.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.CLASS, kotlin.annotation.AnnotationTarget.FUNCTION, kotlin.annotation.AnnotationTarget.PROPERTY}) public @interface InternalTextApi {
}
diff --git a/compose/ui/ui-text/samples/src/main/java/androidx/compose/ui/text/samples/AnnotatedStringFromHtmlSamples.kt b/compose/ui/ui-text/samples/src/main/java/androidx/compose/ui/text/samples/AnnotatedStringFromHtmlSamples.kt
new file mode 100644
index 0000000000000..c06827212081c
--- /dev/null
+++ b/compose/ui/ui-text/samples/src/main/java/androidx/compose/ui/text/samples/AnnotatedStringFromHtmlSamples.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.ui.text.samples
+
+import androidx.annotation.Sampled
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.parseAsHtml
+
+@Composable
+@Sampled
+fun AnnotatedStringFromHtml() {
+ // First, download a string as a plain text using one of the resources' methods. At this stage
+ // you will be handling plurals and formatted strings in needed. Moreover, the string will be
+ // resolved with respect to the current locale and available translations.
+ val string = stringResource(id = R.string.example)
+
+ // Next, convert a string marked with HTML tags into AnnotatedString to be displayed by Text
+ val styledAnnotatedString = string.parseAsHtml()
+
+ BasicText(styledAnnotatedString)
+}
diff --git a/compose/ui/ui-text/samples/src/main/res/values/styled-string-for-sample.xml b/compose/ui/ui-text/samples/src/main/res/values/styled-string-for-sample.xml
new file mode 100644
index 0000000000000..5c0272e1f5195
--- /dev/null
+++ b/compose/ui/ui-text/samples/src/main/res/values/styled-string-for-sample.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+ <b>bold</b>
+ <i>italic</i>
+ <big>big</big>
+ <small>small</small>
+ <font face="monospace">monospace</font>
+ <font face="serif">serif</font>
+ <font face="sans_serif">sans_serif</font>
+ <font face="cursive">cursive</font>
+ <font color="#00ff00">green</font>
+ <tt>monospace</tt>
+ <sup>superscript</sup>
+ <strike>strikethrough</strike>
+ <sub>subscript</sub>
+ <u>underline</u>
+ <span style="background-color:#ff0000">span</span>
+ <p dir="rtl">right to left</p>
+ <p dir="ltr">left to right</p>
+ I am <div>div</div> element.<br>
+ <a href="https://developer.android.com">Link</a>
+
+
diff --git a/compose/ui/ui-text/src/androidInstrumentedTest/AndroidManifest.xml b/compose/ui/ui-text/src/androidInstrumentedTest/AndroidManifest.xml
new file mode 100644
index 0000000000000..934bf4baf12c4
--- /dev/null
+++ b/compose/ui/ui-text/src/androidInstrumentedTest/AndroidManifest.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
diff --git a/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/AnnotatedStringFromHtmlTest.kt b/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/AnnotatedStringFromHtmlTest.kt
new file mode 100644
index 0000000000000..3f9ca4956650c
--- /dev/null
+++ b/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/AnnotatedStringFromHtmlTest.kt
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.ui.text
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.BaselineShift
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.unit.em
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class AnnotatedStringFromHtmlTest {
+ @get:Rule
+ val rule = createComposeRule()
+
+ @Test
+ // pre-N block-level elements were separated with two new lines
+ @SdkSuppress(minSdkVersion = 24)
+ fun buildAnnotatedString_fromHtml() {
+ rule.setContent {
+ val expected = buildAnnotatedString {
+ fun add(block: () -> Unit) {
+ block()
+ append("a")
+ pop()
+ append(" ")
+ }
+ fun addStyle(style: SpanStyle) {
+ add { pushStyle(style) }
+ }
+
+ add { pushLink(LinkAnnotation.Url("https://example.com")) }
+ addStyle(SpanStyle(fontWeight = FontWeight.Bold))
+ addStyle(SpanStyle(fontSize = 1.25.em))
+ append("\na\n") //
+ addStyle(SpanStyle(fontFamily = FontFamily.Serif))
+ addStyle(SpanStyle(color = Color.Green))
+ addStyle(SpanStyle(fontStyle = FontStyle.Italic))
+ append("\na\n") //
+ addStyle(SpanStyle(textDecoration = TextDecoration.LineThrough))
+ addStyle(SpanStyle(fontSize = 0.8.em))
+ addStyle(SpanStyle(background = Color.Red))
+ addStyle(SpanStyle(baselineShift = BaselineShift.Subscript))
+ addStyle(SpanStyle(baselineShift = BaselineShift.Superscript))
+ addStyle(SpanStyle(fontFamily = FontFamily.Monospace))
+ addStyle(SpanStyle(textDecoration = TextDecoration.Underline))
+ }
+
+ val actual = stringResource(androidx.compose.ui.text.test.R.string.html).parseAsHtml()
+
+ assertThat(actual.text).isEqualTo(expected.text)
+ assertThat(actual.spanStyles).containsExactlyElementsIn(expected.spanStyles).inOrder()
+ assertThat(actual.paragraphStyles)
+ .containsExactlyElementsIn(expected.paragraphStyles)
+ .inOrder()
+ assertThat(actual.getStringAnnotations(0, actual.length))
+ .containsExactlyElementsIn(expected.getStringAnnotations(0, expected.length))
+ .inOrder()
+ assertThat(actual.getLinkAnnotations(0, actual.length))
+ .containsExactlyElementsIn(expected.getLinkAnnotations(0, expected.length))
+ .inOrder()
+ }
+ }
+
+ @Test
+ fun formattedString_withStyling() {
+ rule.setContent {
+ val actual = stringResource(
+ androidx.compose.ui.text.test.R.string.formatting,
+ "computer"
+ ).parseAsHtml()
+ assertThat(actual.text).isEqualTo("Hello, computer!")
+ assertThat(actual.spanStyles).containsExactly(
+ AnnotatedString.Range(SpanStyle(fontWeight = FontWeight.Bold), 7, 15)
+ )
+ }
+ }
+}
diff --git a/compose/ui/ui-text/src/androidInstrumentedTest/res/values/styled-string-for-test.xml b/compose/ui/ui-text/src/androidInstrumentedTest/res/values/styled-string-for-test.xml
new file mode 100644
index 0000000000000..e2fd514ca76a9
--- /dev/null
+++ b/compose/ui/ui-text/src/androidInstrumentedTest/res/values/styled-string-for-test.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+ <a href="https://example.com">a</a>
+ <b>a</b>
+ <big>a</big>
+ <div>a</div>
+ <font face="serif">a</font>
+ <font color="#00ff00">a</font>
+ <i>a</i>
+ <p>a</p>
+ <s>a</s>
+ <small>a</small>
+ <span style="background-color:red">a</span>
+ <sub>a</sub>
+ <sup>a</sup>
+ <tt>a</tt>
+ <u>a</u>
+
+ Hello, <b>%s</b>!
+
diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/Html.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/Html.android.kt
new file mode 100644
index 0000000000000..fdae6205ce11a
--- /dev/null
+++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/Html.android.kt
@@ -0,0 +1,163 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.ui.text
+
+import android.graphics.Typeface
+import android.os.Build
+import android.text.Annotation
+import android.text.Layout
+import android.text.Spanned
+import android.text.style.AbsoluteSizeSpan
+import android.text.style.AlignmentSpan
+import android.text.style.BackgroundColorSpan
+import android.text.style.ForegroundColorSpan
+import android.text.style.RelativeSizeSpan
+import android.text.style.StrikethroughSpan
+import android.text.style.StyleSpan
+import android.text.style.SubscriptSpan
+import android.text.style.SuperscriptSpan
+import android.text.style.TypefaceSpan
+import android.text.style.URLSpan
+import android.text.style.UnderlineSpan
+import androidx.annotation.DoNotInline
+import androidx.annotation.RequiresApi
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.BaselineShift
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.unit.em
+import androidx.core.text.HtmlCompat
+
+actual fun String.parseAsHtml(): AnnotatedString {
+ val spanned = HtmlCompat.fromHtml(this, HtmlCompat.FROM_HTML_MODE_COMPACT)
+ return spanned.toAnnotatedString()
+}
+
+private fun Spanned.toAnnotatedString(): AnnotatedString {
+ return AnnotatedString.Builder(capacity = length)
+ .append(this)
+ .also { it.addSpans(this) }
+ .toAnnotatedString()
+}
+
+private fun AnnotatedString.Builder.addSpans(spanned: Spanned) {
+ spanned.getSpans(0, length, Any::class.java).forEach { span ->
+ val range = TextRange(spanned.getSpanStart(span), spanned.getSpanEnd(span))
+ addSpan(span, range.start, range.end)
+ }
+}
+
+private fun AnnotatedString.Builder.addSpan(span: Any, start: Int, end: Int) {
+ when (span) {
+ is AbsoluteSizeSpan -> {
+ // TODO(soboleva) need density object or make dip/px new units in TextUnit
+ }
+ is AlignmentSpan -> {
+ addStyle(span.toParagraphStyle(), start, end)
+ }
+ is Annotation -> {
+ // TODO(soboleva) handle this via TagHandler
+ }
+ is BackgroundColorSpan -> {
+ addStyle(SpanStyle(background = Color(span.backgroundColor)), start, end)
+ }
+ is ForegroundColorSpan -> {
+ addStyle(SpanStyle(color = Color(span.foregroundColor)), start, end)
+ }
+ is RelativeSizeSpan -> {
+ addStyle(SpanStyle(fontSize = span.sizeChange.em), start, end)
+ }
+ is StrikethroughSpan -> {
+ addStyle(SpanStyle(textDecoration = TextDecoration.LineThrough), start, end)
+ }
+ is StyleSpan -> {
+ span.toSpanStyle()?.let { addStyle(it, start, end) }
+ }
+ is SubscriptSpan -> {
+ addStyle(SpanStyle(baselineShift = BaselineShift.Subscript), start, end)
+ }
+ is SuperscriptSpan -> {
+ addStyle(SpanStyle(baselineShift = BaselineShift.Superscript), start, end)
+ }
+ is TypefaceSpan -> {
+ addStyle(span.toSpanStyle(), start, end)
+ }
+ is UnderlineSpan -> {
+ addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start, end)
+ }
+ is URLSpan -> {
+ span.url?.let {
+ addLink(LinkAnnotation.Url(it), start, end)
+ }
+ }
+ }
+}
+
+private fun AlignmentSpan.toParagraphStyle(): ParagraphStyle {
+ val alignment = when (this.alignment) {
+ Layout.Alignment.ALIGN_NORMAL -> TextAlign.Start
+ Layout.Alignment.ALIGN_CENTER -> TextAlign.Center
+ Layout.Alignment.ALIGN_OPPOSITE -> TextAlign.End
+ else -> TextAlign.Unspecified
+ }
+ return ParagraphStyle(textAlign = alignment)
+}
+
+private fun StyleSpan.toSpanStyle(): SpanStyle? {
+ /** StyleSpan doc: styles are cumulative -- if both bold and italic are set in
+ * separate spans, or if the base style is bold and a span calls for italic,
+ * you get bold italic. You can't turn off a style from the base style.
+ */
+ return when (style) {
+ Typeface.BOLD -> {
+ SpanStyle(fontWeight = FontWeight.Bold)
+ }
+ Typeface.ITALIC -> {
+ SpanStyle(fontStyle = FontStyle.Italic)
+ }
+ Typeface.BOLD_ITALIC -> {
+ SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)
+ }
+ else -> null
+ }
+}
+
+private fun TypefaceSpan.toSpanStyle(): SpanStyle {
+ var fontFamily: FontFamily? = null
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ fontFamily = Api28Impl.createFontFamilyFromTypeface(this)
+ }
+ if (fontFamily == null) fontFamily = when (family) {
+ null -> null
+ FontFamily.Cursive.name -> FontFamily.Cursive
+ FontFamily.Monospace.name -> FontFamily.Monospace
+ FontFamily.SansSerif.name -> FontFamily.SansSerif
+ FontFamily.Serif.name -> FontFamily.Serif
+ else -> FontFamily.Default
+ }
+ return SpanStyle(fontFamily = fontFamily)
+}
+
+@RequiresApi(28)
+private object Api28Impl {
+ @DoNotInline
+ fun createFontFamilyFromTypeface(typefaceSpan: TypefaceSpan) =
+ typefaceSpan.typeface?.let { FontFamily(it) }
+}
diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/Html.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/Html.kt
new file mode 100644
index 0000000000000..3332e6da316b9
--- /dev/null
+++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/Html.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.ui.text
+
+/**
+ * Converts a string with HTML tags into [AnnotatedString].
+ *
+ * If you define your string in the resources, make sure to use HTML-escaped opening brackets
+ * "<" instead of "<".
+ *
+ * For a list of supported tags go check
+ * [Styling with HTML markup](https://developer.android.com/guide/topics/resources/string-resource#StylingWithHTML)
+ * guide. Note that bullet lists and custom annotations are not **yet** available.
+ *
+ * Example of displaying styled string from resources
+ * @sample androidx.compose.ui.text.samples.AnnotatedStringFromHtml
+ */
+expect fun String.parseAsHtml(): AnnotatedString
diff --git a/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/Html.skiko.kt b/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/Html.skiko.kt
new file mode 100644
index 0000000000000..20a8f27cf61f1
--- /dev/null
+++ b/compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/Html.skiko.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.ui.text
+
+/**
+ * TBD: not yet implemented.
+ *
+ * Converts a string with HTML tags into [AnnotatedString].
+ */
+actual fun String.parseAsHtml(): AnnotatedString {
+ return AnnotatedString(this)
+}