From 932545d475ba776cfa5ca6574196429daf3923b1 Mon Sep 17 00:00:00 2001 From: Anastasia Soboleva Date: Thu, 14 Mar 2024 08:41:05 +0000 Subject: [PATCH] Add method for creating AnnotatedString from HTML tagged string This method uses HtmlCompat.fromHtml under the hood. Note that unlike getText(resourceId), it doesn't handle custom annotation tags and therefore needs to be handled separately. At the same time, getText(resourceId) doesn't support formatting. The method will be used like so: getString(R.string.example).parseAsHtml() In the future I'm planning to extend parseAsHtml with links styling and links click handler objects. Also note that original HtmlCompat.fromHtml accepts flags which we should consider exposing in the future. Test: demo Test: added tests AnnotatedStringFromHtmlTest Relnote: "Added `parseAsHtml` method for styled strings: it allows to convert a string marked with HTML tags into AnnotatedString. Note that not all tags are supported, for example you won't be able to display bullet lists yet." Change-Id: I84d3d1881805e964cea940eb1c68a5bba16f6416 --- .../foundation/demos/text/TextDemos.kt | 2 + compose/ui/ui-text/api/current.txt | 4 + compose/ui/ui-text/api/restricted_current.txt | 4 + .../samples/AnnotatedStringFromHtmlSamples.kt | 37 ++++ .../res/values/styled-string-for-sample.xml | 40 +++++ .../AndroidManifest.xml | 20 +++ .../ui/text/AnnotatedStringFromHtmlTest.kt | 104 +++++++++++ .../res/values/styled-string-for-test.xml | 37 ++++ .../androidx/compose/ui/text/Html.android.kt | 163 ++++++++++++++++++ .../kotlin/androidx/compose/ui/text/Html.kt | 32 ++++ .../androidx/compose/ui/text/Html.skiko.kt | 26 +++ 11 files changed, 469 insertions(+) create mode 100644 compose/ui/ui-text/samples/src/main/java/androidx/compose/ui/text/samples/AnnotatedStringFromHtmlSamples.kt create mode 100644 compose/ui/ui-text/samples/src/main/res/values/styled-string-for-sample.xml create mode 100644 compose/ui/ui-text/src/androidInstrumentedTest/AndroidManifest.xml create mode 100644 compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/AnnotatedStringFromHtmlTest.kt create mode 100644 compose/ui/ui-text/src/androidInstrumentedTest/res/values/styled-string-for-test.xml create mode 100644 compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/Html.android.kt create mode 100644 compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/Html.kt create mode 100644 compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/Html.skiko.kt 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) +}