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) +}