
androidAndroidTest.androidx.compose.ui.text.ParagraphFillBoundingBoxesTest.kt Maven / Gradle / Ivy
/*
* Copyright 2022 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.geometry.Rect
import androidx.compose.ui.text.font.createFontFamilyResolver
import androidx.compose.ui.text.font.toFontFamily
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextGeometricTransform
import androidx.compose.ui.text.style.TextIndent
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SdkSuppress
import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import androidx.compose.ui.text.matchers.assertThat
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.text.style.LineHeightStyle.Trim
import androidx.compose.ui.text.style.LineHeightStyle.Alignment
import androidx.compose.ui.unit.Constraints
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@SmallTest
class ParagraphFillBoundingBoxesTest {
private val fontFamilyMeasureFont = FontTestData.BASIC_MEASURE_FONT.toFontFamily()
private val fontFamilyResolver = createFontFamilyResolver(
InstrumentationRegistry.getInstrumentation().context
)
private val defaultDensity = Density(density = 1f)
private val fontSize = 10.sp
private val fontSizeInPx = with(defaultDensity) { fontSize.toPx() }
@Test(expected = IllegalArgumentException::class)
fun negativeStart() {
val paragraph = Paragraph("a")
paragraph.getBoundingBoxes(TextRange(1, 1))
}
@Test(expected = IllegalArgumentException::class)
fun startEqualToLength() {
val paragraph = Paragraph("a")
paragraph.getBoundingBoxes(TextRange(1, 1))
}
@Test(expected = IllegalArgumentException::class)
fun endGreaterThanLength() {
val paragraph = Paragraph("a")
paragraph.getBoundingBoxes(TextRange(0, 2))
}
@Test(expected = IllegalArgumentException::class)
fun endEqualToStart() {
val paragraph = Paragraph("a")
paragraph.getBoundingBoxes(TextRange(0, 0))
}
@Test(expected = IllegalArgumentException::class)
fun arraySizeSmallerThanTextLength() {
val text = "abc"
val paragraph = Paragraph(text)
val array = FloatArray(text.length * 4 - 1)
paragraph.fillBoundingBoxes(TextRange(0, text.length), array, 0)
}
@Test(expected = IllegalArgumentException::class)
fun arraySizeSmallerThanRange() {
val text = "abc"
val paragraph = Paragraph(text)
val startIndex = 1
val endIndex = text.length
val array = FloatArray((endIndex - startIndex) * 4 - 1)
paragraph.fillBoundingBoxes(TextRange(startIndex, endIndex), array, 0)
}
@Test(expected = IllegalArgumentException::class)
fun arraySizeSmallerThanTextLengthWithStart() {
val text = "abc"
val paragraph = Paragraph(text)
val startIndex = 0
val endIndex = text.length
val array = FloatArray(text.length * 8)
val arrayStart = text.length * 4 + 1
paragraph.fillBoundingBoxes(TextRange(startIndex, endIndex), array, arrayStart)
}
@Test
fun singleCharacter() {
val text = "a"
val paragraph = Paragraph(text)
assertThat(
paragraph.getBoundingBoxes(TextRange(0, text.length))
).isEqualToWithTolerance(ltrCharacterBoundariesForTestFont(text))
}
@Test
fun arrayFillStartsFromStartOffsetEndsAtEndOffset() {
val text = "abc"
val paragraph = Paragraph(text)
val arraySizeToFill = text.length * 4
// provide 3 times the array, first and last sections should not be filled.
// fill with min value to check the not-filled indices
val array = FloatArray(arraySizeToFill * 3) { Float.MIN_VALUE }
// start filling from arraySizeToFill
paragraph.fillBoundingBoxes(TextRange(0, text.length), array, arraySizeToFill)
// first section is not changed
for (index in 0 until arraySizeToFill) {
assertThat(array[index]).isEqualTo(Float.MIN_VALUE)
}
// data is added to the middle section, and not equal to MIN_VALUE
for (index in arraySizeToFill until (2 * arraySizeToFill)) {
assertThat(array[index]).isNotEqualTo(Float.MIN_VALUE)
}
// last section is not changed
for (index in 2 * arraySizeToFill until (3 * arraySizeToFill)) {
assertThat(array[index]).isEqualTo(Float.MIN_VALUE)
}
}
@Test
fun overridesArray() {
val text = "abc"
val range = TextRange(0, text.length)
val array = FloatArray(range.length * 4)
val paragraph1 = Paragraph(text, style = TextStyle(fontSize = fontSize))
paragraph1.fillBoundingBoxes(range, array, 0)
assertThat(array.asRectArray()).isEqualToWithTolerance(
ltrCharacterBoundariesForTestFont(text, fontSizeInPx)
)
val paragraph2 = Paragraph(text, style = TextStyle(fontSize = (fontSize * 2)))
paragraph2.fillBoundingBoxes(range, array, 0)
// the same array is overridden with new and different values
assertThat(array.asRectArray()).isEqualToWithTolerance(
ltrCharacterBoundariesForTestFont(text, fontSizeInPx * 2)
)
}
@Test
fun singleCharacterLineHeight() {
val lineHeight = fontSize * 2
val text = "a"
val paragraph = Paragraph(text, style = TextStyle(lineHeight = lineHeight))
// first line line height is ignored, therefore the result is the same as without line
// height
assertThat(
paragraph.getBoundingBoxes(TextRange(0, text.length))
).isEqualToWithTolerance(ltrCharacterBoundariesForTestFont(text))
}
@OptIn(ExperimentalTextApi::class)
@Test
fun singleCharacterLineHeight_includeFontPaddingIsFalse() {
val lineHeight = fontSize * 2
val lineHeightInPx = with(defaultDensity) { lineHeight.toPx() }
val text = "a"
val paragraph = Paragraph(
text,
style = TextStyle(
lineHeight = lineHeight,
platformStyle = @Suppress("DEPRECATION") PlatformTextStyle(
includeFontPadding = false
),
lineHeightStyle = LineHeightStyle(
alignment = Alignment.Proportional,
trim = Trim.None
)
),
)
assertThat(
paragraph.getBoundingBoxes(TextRange(0, text.length))
).isEqualToWithTolerance(
ltrCharacterBoundariesForTestFont(
text = text,
fontSizeInPx = fontSizeInPx,
lineHeightInPx = lineHeightInPx
)
)
}
@OptIn(ExperimentalTextApi::class)
@Test
fun multiLineCharacterLineHeight() {
val lineHeight = fontSize * 2
val lineHeightInPx = with(defaultDensity) { lineHeight.toPx() }
val text = "a\na\na"
@Suppress("DEPRECATION")
val paragraph = Paragraph(
text,
style = TextStyle(
lineHeight = lineHeight,
lineHeightStyle = LineHeightStyle(
alignment = Alignment.Proportional,
trim = Trim.None
),
platformStyle = @Suppress("DEPRECATION") PlatformTextStyle(
includeFontPadding = false
)
)
)
assertThat(
paragraph.getBoundingBoxes(TextRange(0, text.length))
).isEqualToWithTolerance(
ltrCharacterBoundariesForTestFont(
text = text,
fontSizeInPx = fontSizeInPx,
lineHeightInPx = lineHeightInPx
)
)
}
@Test
fun singleCharacterRtl() {
val text = "\u05D0"
val width = text.length * 2 * fontSizeInPx // a width wider than text
val paragraph = Paragraph(
text = text,
width = width,
style = TextStyle(textDirection = TextDirection.Content)
)
assertThat(
paragraph.getBoundingBoxes(TextRange(0, text.length))
).isEqualToWithTolerance(rtlCharacterBoundariesForTestFont(text, width))
}
@Test
fun singleLineLtr() {
val text = "abc"
val paragraph = Paragraph(text)
assertThat(
paragraph.getBoundingBoxes(TextRange(0, text.length))
).isEqualToWithTolerance(ltrCharacterBoundariesForTestFont(text))
}
@Test
fun singleLineRtl() {
val text = "\u05D0\u05D1\u05D2"
val width = text.length * 2 * fontSizeInPx // a width wider than text
val paragraph = Paragraph(text, width = width)
assertThat(
paragraph.getBoundingBoxes(TextRange(0, text.length))
).isEqualToWithTolerance(rtlCharacterBoundariesForTestFont(text, width))
}
@Test
fun bidiLtrLine() {
val text = "a" + "\u05D0\u05D1" + "b"
val paragraph = Paragraph(text)
val expected = ltrCharacterBoundariesForTestFont(text)
// text with indices 0123 is rendered as 0213
assertThat(
paragraph.getBoundingBoxes(TextRange(0, text.length))
).isEqualToWithTolerance(arrayOf(expected[0], expected[2], expected[1], expected[3]))
}
@Test
fun bidiRtlLine() {
val text = "\u05D0" + "ab" + "\u05D1"
val width = text.length * 2 * fontSizeInPx // a width wider than text
val paragraph = Paragraph(width = width, text = text)
val expected = rtlCharacterBoundariesForTestFont(text, width)
// text with indices 0123 is rendered as 3120
assertThat(
paragraph.getBoundingBoxes(TextRange(0, text.length))
).isEqualToWithTolerance(arrayOf(expected[0], expected[2], expected[1], expected[3]))
}
@Test
fun multiLineLtr() {
val text = "a\nb\nc"
val paragraph = Paragraph(text)
assertThat(
paragraph.getBoundingBoxes(TextRange(0, text.length))
).isEqualToWithTolerance(ltrCharacterBoundariesForTestFont(text))
}
@Test
fun multiLineRtl() {
val text = "\u05D0\n\u05D1\n\u05D2"
val width = 3 * fontSizeInPx // a width wider than paragraph
val paragraph = Paragraph(width = width, text = text)
assertThat(
paragraph.getBoundingBoxes(TextRange(0, text.length))
).isEqualToWithTolerance(rtlCharacterBoundariesForTestFont(text, width))
}
@Test
@SdkSuppress(minSdkVersion = 24)
fun zwjEmoji() {
// Emoji 2.0 - family: man, woman, girl, boy
// 2.0 released in Nov 2015; min version is set to SDK 24 which was released in 2016
val text = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66"
val paragraph = Paragraph(text)
val expected = paragraph.getBoundingBoxes(TextRange(0, text.length))
// since we do not use the test font, the first rect should be non-zero
// the remaining characters should have 0 width starting from the right of the
// first character
val initialRect = expected[0]
assertThat(initialRect.width).isNonZero()
for (index in 1 until expected.size) {
assertThat(expected[index]).isEqualToWithTolerance(
Rect(initialRect.right, initialRect.top, initialRect.right, initialRect.bottom)
)
}
}
// API 28 and below adds indent while calculating the right of the character
// at the line end. should fix in the main code with a behavior switch before and after API 29.
@SdkSuppress(minSdkVersion = 29)
@Test
fun withIndent() {
val firstIndent = fontSize * 2
val restIndent = fontSize
val firstIndentInPx = with(defaultDensity) { firstIndent.toPx() }
val restIndentInPx = with(defaultDensity) { restIndent.toPx() }
val text = "abcd\ne"
val paragraph = Paragraph(
width = 3f * fontSizeInPx, // first indent is 2 char + 1 char will reach line break
text = text,
style = TextStyle(
textIndent = TextIndent(firstLine = firstIndent, restLine = restIndent)
)
)
// will be rendered as
// _ _ a
// _ b c
// _ d \n
// _ _ e
val firstLeft = firstIndentInPx
val restLeft = restIndentInPx
assertThat(
paragraph.getBoundingBoxes(TextRange(0, text.length))
).isEqualToWithTolerance(
arrayOf(
// a
Rect(firstLeft, 0f, firstLeft + fontSizeInPx, fontSizeInPx),
// b
Rect(restLeft, fontSizeInPx, restLeft + fontSizeInPx, 2 * fontSizeInPx),
// c
Rect(
restLeft + fontSizeInPx,
fontSizeInPx,
restLeft + 2 * fontSizeInPx,
2 * fontSizeInPx
),
// d
Rect(restLeft, 2 * fontSizeInPx, restLeft + fontSizeInPx, 3 * fontSizeInPx),
// \n
Rect(
restLeft + fontSizeInPx,
2 * fontSizeInPx,
restLeft + fontSizeInPx,
3 * fontSizeInPx
),
// e
Rect(firstLeft, 3 * fontSizeInPx, firstLeft + fontSizeInPx, 4 * fontSizeInPx),
)
)
}
@Test
fun variableFontSize() {
val doubleFontSize = fontSize * 2
val doubleFontSizeInPx = with(defaultDensity) { doubleFontSize.toPx() }
val text = buildAnnotatedString {
append("a")
withStyle(SpanStyle(fontSize = doubleFontSize)) {
append("b")
}
append("c")
toAnnotatedString()
}
val paragraph = Paragraph(text)
assertThat(
paragraph.getBoundingBoxes(TextRange(0, text.length))
).isEqualToWithTolerance(
arrayOf(
// 1 width for a, height is doubleFontSize since line metrics change
Rect(0f, 0f, fontSizeInPx, doubleFontSizeInPx),
// 2 width for b
Rect(fontSizeInPx, 0f, 3 * fontSizeInPx, doubleFontSizeInPx),
// 1 width for c
Rect(3 * fontSizeInPx, 0f, 4 * fontSizeInPx, doubleFontSizeInPx)
)
)
}
@Test
fun letterSpacing() {
val text = "abc\nde"
val paragraph = Paragraph(
text = text,
style = TextStyle(letterSpacing = 1.em)
)
assertThat(
paragraph.getBoundingBoxes(TextRange(0, text.length))
).isEqualToWithTolerance(
arrayOf(
// a
Rect(0f, 0f, 2 * fontSizeInPx, fontSizeInPx),
// b
Rect(2 * fontSizeInPx, 0f, 4 * fontSizeInPx, fontSizeInPx),
// c
Rect(4 * fontSizeInPx, 0f, 6 * fontSizeInPx, fontSizeInPx),
// \n
Rect(6 * fontSizeInPx, 0f, 6 * fontSizeInPx, fontSizeInPx),
// c
Rect(0f, fontSizeInPx, 2 * fontSizeInPx, 2 * fontSizeInPx),
// d
Rect(2 * fontSizeInPx, fontSizeInPx, 4 * fontSizeInPx, 2 * fontSizeInPx)
)
)
}
@Test
fun textAlignCenter() {
val text = "ab"
val paragraph = Paragraph(
width = text.length * fontSizeInPx * 2,
text = text,
style = TextStyle(textAlign = TextAlign.Center)
)
assertThat(
paragraph.getBoundingBoxes(TextRange(0, text.length))
).isEqualToWithTolerance(
arrayOf(
Rect(fontSizeInPx, 0f, 2 * fontSizeInPx, fontSizeInPx),
Rect(2 * fontSizeInPx, 0f, 3 * fontSizeInPx, fontSizeInPx),
)
)
}
@Test
fun textAlignEnd() {
val text = "ab"
val paragraph = Paragraph(
width = text.length * fontSizeInPx * 2,
text = text,
style = TextStyle(textAlign = TextAlign.End)
)
assertThat(
paragraph.getBoundingBoxes(TextRange(0, text.length))
).isEqualToWithTolerance(
arrayOf(
Rect(2 * fontSizeInPx, 0f, 3 * fontSizeInPx, fontSizeInPx),
Rect(3 * fontSizeInPx, 0f, 4 * fontSizeInPx, fontSizeInPx),
)
)
}
@Test
fun withTextGeometricTransformScaleX() {
val text = "ab"
val paragraph = Paragraph(
text = text,
style = TextStyle(textGeometricTransform = TextGeometricTransform(scaleX = 2.0f))
)
assertThat(
paragraph.getBoundingBoxes(TextRange(0, text.length))
).isEqualToWithTolerance(
arrayOf(
Rect(0f, 0f, 2 * fontSizeInPx, fontSizeInPx),
Rect(2 * fontSizeInPx, 0f, 4 * fontSizeInPx, fontSizeInPx)
)
)
}
@Test
fun textGeometricTransformSkewX() {
val text = "ab"
val paragraph = Paragraph(
text = text,
style = TextStyle(textGeometricTransform = TextGeometricTransform(skewX = -1f))
)
// skew does not change the boundary, character glyph goes outside of boundary
assertThat(
paragraph.getBoundingBoxes(TextRange(0, text.length))
).isEqualToWithTolerance(
arrayOf(
Rect(0f, 0f, fontSizeInPx, fontSizeInPx),
Rect(fontSizeInPx, 0f, 2 * fontSizeInPx, fontSizeInPx)
)
)
}
@Test
fun baselineShift() {
val shiftedFontSize = fontSize / 2f
val shiftedFontSizeInPx = with(defaultDensity) { shiftedFontSize.toPx() }
val text = buildAnnotatedString {
append("a")
withStyle(
SpanStyle(
baselineShift = BaselineShift.Superscript,
fontSize = shiftedFontSize
)
) {
append("b")
}
append("c")
toAnnotatedString()
}
val paragraph = Paragraph(text, width = 6 * fontSizeInPx)
val shiftedStart = fontSizeInPx
val shiftedEnd = shiftedStart + shiftedFontSizeInPx
// shifted bottom and top still points to line bottom and top
assertThat(
paragraph.getBoundingBoxes(TextRange(0, text.length))
).isEqualToWithTolerance(
arrayOf(
// a
Rect(0f, 0f, fontSizeInPx, fontSizeInPx),
// b
Rect(shiftedStart, 0f, shiftedEnd, fontSizeInPx),
// c
Rect(shiftedEnd, 0f, shiftedEnd + fontSizeInPx, fontSizeInPx)
)
)
}
@Test
fun inlineElement() {
val doubleFontSize = fontSize * 2
val doubleFontSizeInPx = with(defaultDensity) { doubleFontSize.toPx() }
val text = "abc"
val paragraph = Paragraph(
text = text,
placeholders = listOf(
AnnotatedString.Range(
item = Placeholder(
width = doubleFontSize,
height = doubleFontSize,
placeholderVerticalAlign = PlaceholderVerticalAlign.Top
),
start = 1,
end = 2
)
)
)
assertThat(
paragraph.getBoundingBoxes(TextRange(0, text.length))
).isEqualToWithTolerance(
arrayOf(
Rect(0f, 0f, fontSizeInPx, doubleFontSizeInPx),
Rect(fontSizeInPx, 0f, 3 * fontSizeInPx, doubleFontSizeInPx),
Rect(3 * fontSizeInPx, 0f, 4 * fontSizeInPx, doubleFontSizeInPx)
)
)
}
private fun AndroidParagraph.getBoundingBoxes(range: TextRange): Array {
val arraySize = range.length * 4
val array = FloatArray(arraySize)
this.fillBoundingBoxes(range, array, 0)
return array.asRectArray()
}
private fun ltrCharacterBoundariesForTestFont(
text: String,
fontSizeInPx: Float = this.fontSizeInPx,
// assumes that the test font is used and fontSize is equal to default line height
lineHeightInPx: Float = fontSizeInPx,
initialTop: Float = 0f
): Array =
getLtrCharacterBoundariesForTestFont(text, fontSizeInPx, lineHeightInPx, initialTop)
private fun rtlCharacterBoundariesForTestFont(text: String, width: Float): Array =
getRtlCharacterBoundariesForTestFont(text, width, fontSizeInPx)
private fun Paragraph(
text: String,
style: TextStyle? = null,
width: Float = Float.MAX_VALUE,
placeholders: List> = listOf()
): AndroidParagraph = Paragraph(AnnotatedString(text), style, width, placeholders)
private fun Paragraph(
text: AnnotatedString,
style: TextStyle? = null,
width: Float = Float.MAX_VALUE,
placeholders: List> = listOf()
): AndroidParagraph {
return Paragraph(
text = text.text,
style = TextStyle(
fontSize = fontSize,
fontFamily = fontFamilyMeasureFont,
textDirection = TextDirection.Content
).merge(style),
spanStyles = text.spanStyles,
placeholders = placeholders,
maxLines = Int.MAX_VALUE,
ellipsis = false,
constraints = Constraints(maxWidth = width.ceilToInt()),
density = defaultDensity,
fontFamilyResolver = fontFamilyResolver
) as AndroidParagraph
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy