Skip to content

Commit e233142

Browse files
lazergmeta-codesync[bot]
authored andcommitted
fix(android): clamp text decoration offsets to truncated layout (#58366)
Summary: On Android, a `Text` with `numberOfLines` can crash when a nested `Text` has `textDecorationLine: 'underline'` or `'line-through'` and the decorated span starts or ends past what the truncated layout actually rendered. `drawSpannedDecoration()` reads `start`/`end` from the full, untruncated `Spanned` text and passes them straight to `Layout.getPrimaryHorizontal()`, which throws `IndexOutOfBoundsException` once numberOfLines cuts the layout shorter than the span. This clamps both offsets to the last position the truncated (and possibly tail-ellipsized) layout can actually resolve before using them, so a partially hidden span draws its decoration up to the visible boundary and a fully hidden span draws nothing, instead of crashing. ## Changelog: [ANDROID] [FIXED] - Fix crash drawing text decoration (underline/strikethrough) when a nested Text's span extends past a numberOfLines-truncated layout Pull Request resolved: #58366 Test Plan: Added two regression tests in `TextDecorationStyleTest`: both build a single-line, tail-ellipsized `StaticLayout`. One checks clipping a span that starts visible and extends beyond the layout; the other checks the zero-length line for a fully hidden span. The formatting/API-annotation correction leaves the production source, test bodies, and assertions unchanged. Exact parent test run: https://www.internalfb.com/intern/testinfra/testrun/20829148333669211. Fixes #58356 Differential Revision: D125060226 Pulled By: Abbondanzo
1 parent b8ec87e commit e233142

2 files changed

Lines changed: 115 additions & 4 deletions

File tree

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextDecorationStyle.kt‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,26 @@ private fun drawDecorationLine(
111111
}
112112
}
113113

114+
/**
115+
* The last offset [layout] can resolve a horizontal position for. Truncation (via `numberOfLines`)
116+
* can leave the last line shorter than [Layout.getLineEnd] reports, since [layout] keeps the full
117+
* untruncated text and only clips how much of it is laid out. When the last line ends in a tail
118+
* ellipsis, anything from the ellipsis onward is unresolvable too; a leading/middle ellipsis
119+
* doesn't shorten what's resolvable on that line, so it's left to [Layout.getLineEnd].
120+
*/
121+
private fun visibleTextEnd(layout: Layout): Int {
122+
val lastLine = layout.lineCount - 1
123+
val lineStart = layout.getLineStart(lastLine)
124+
val lineEnd = layout.getLineEnd(lastLine)
125+
val ellipsisStart = layout.getEllipsisStart(lastLine)
126+
val ellipsisCount = layout.getEllipsisCount(lastLine)
127+
return if (ellipsisCount > 0 && ellipsisStart + ellipsisCount == lineEnd - lineStart) {
128+
lineStart + ellipsisStart
129+
} else {
130+
lineEnd
131+
}
132+
}
133+
114134
/**
115135
* Shared decoration drawing entry point used by [ReactUnderlineSpan] and [ReactStrikethroughSpan].
116136
* Computes a density-aware stroke thickness, sets up a dedicated paint (to avoid mutating the
@@ -174,13 +194,19 @@ internal fun drawSpannedDecoration(
174194
}
175195
}
176196

177-
val startLine = layout.getLineForOffset(start)
178-
val endLine = layout.getLineForOffset(end)
197+
val visibleEnd = visibleTextEnd(layout)
198+
val clampedStart = min(start, visibleEnd)
199+
val clampedEnd = min(end, visibleEnd)
200+
201+
val startLine = layout.getLineForOffset(clampedStart)
202+
val endLine = layout.getLineForOffset(clampedEnd)
179203
for (line in startLine..endLine) {
180204
val baseline = layout.getLineBaseline(line).toFloat()
181205
val rawX1 =
182-
if (line == startLine) layout.getPrimaryHorizontal(start) else layout.getLineLeft(line)
183-
val rawX2 = if (line == endLine) layout.getPrimaryHorizontal(end) else layout.getLineRight(line)
206+
if (line == startLine) layout.getPrimaryHorizontal(clampedStart)
207+
else layout.getLineLeft(line)
208+
val rawX2 =
209+
if (line == endLine) layout.getPrimaryHorizontal(clampedEnd) else layout.getLineRight(line)
184210
// Normalize for RTL text where start may be to the right of end.
185211
val x1 = min(rawX1, rawX2)
186212
val x2 = max(rawX1, rawX2)

‎packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextDecorationStyleTest.kt‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,24 @@
77

88
package com.facebook.react.views.text
99

10+
import android.graphics.Canvas
11+
import android.graphics.Color
12+
import android.os.Build
13+
import android.text.StaticLayout
14+
import android.text.TextPaint
15+
import android.text.TextUtils
16+
import androidx.annotation.RequiresApi
1017
import org.assertj.core.api.Assertions.assertThat
1118
import org.junit.Test
19+
import org.junit.runner.RunWith
20+
import org.mockito.kotlin.any
21+
import org.mockito.kotlin.eq
22+
import org.mockito.kotlin.mock
23+
import org.mockito.kotlin.verify
24+
import org.robolectric.RobolectricTestRunner
1225

26+
@RequiresApi(Build.VERSION_CODES.M)
27+
@RunWith(RobolectricTestRunner::class)
1328
class TextDecorationStyleTest {
1429
@Test
1530
fun fromStringSolid() {
@@ -50,4 +65,74 @@ class TextDecorationStyleTest {
5065
fun fromStringEmptyDefaultsToSolid() {
5166
assertThat(TextDecorationStyle.fromString("")).isEqualTo(TextDecorationStyle.SOLID)
5267
}
68+
69+
@Test
70+
fun drawSpannedDecorationClampsSpanPastTailEllipsis() {
71+
val layout = buildTailEllipsizedLayout()
72+
val visibleEnd = layout.getLineStart(0) + layout.getEllipsisStart(0)
73+
val baseline = layout.getLineBaseline(0).toFloat()
74+
val x1 = layout.getPrimaryHorizontal(0)
75+
val x2 = layout.getPrimaryHorizontal(visibleEnd)
76+
val canvas = mock<Canvas>()
77+
78+
// The span covers the whole string, well past what survived the ellipsis; pre-fix this
79+
// called layout.getPrimaryHorizontal(TAIL_TEXT.length) and crashed with
80+
// IndexOutOfBoundsException, since the ellipsized layout only resolves up to visibleEnd.
81+
drawSpannedDecoration(
82+
0,
83+
TAIL_TEXT.length,
84+
canvas,
85+
layout,
86+
Color.BLACK,
87+
TextDecorationStyle.SOLID,
88+
) { _, lineBaseline, thickness ->
89+
lineBaseline + thickness + 1f
90+
}
91+
92+
verify(canvas).drawLine(eq(x1), eq(baseline + 1f), eq(x2), eq(baseline + 1f), any())
93+
}
94+
95+
@Test
96+
fun drawSpannedDecorationSkipsSpanEntirelyPastTailEllipsis() {
97+
val layout = buildTailEllipsizedLayout()
98+
val visibleEnd = layout.getLineStart(0) + layout.getEllipsisStart(0)
99+
val baseline = layout.getLineBaseline(0).toFloat()
100+
val x = layout.getPrimaryHorizontal(visibleEnd)
101+
val canvas = mock<Canvas>()
102+
103+
// The whole span (e.g. a nested Text) starts after the ellipsis, fully hidden: it must
104+
// collapse to a zero-length line at the visible boundary, not draw anything past it.
105+
drawSpannedDecoration(
106+
visibleEnd,
107+
TAIL_TEXT.length,
108+
canvas,
109+
layout,
110+
Color.BLACK,
111+
TextDecorationStyle.SOLID,
112+
) { _, lineBaseline, thickness ->
113+
lineBaseline + thickness + 1f
114+
}
115+
116+
verify(canvas).drawLine(eq(x), eq(baseline + 1f), eq(x), eq(baseline + 1f), any())
117+
}
118+
119+
/**
120+
* A single line, tail-ellipsized right after "Hello" because the paragraph break in [TAIL_TEXT]
121+
* hides everything after it once `maxLines` is reached.
122+
*/
123+
private fun buildTailEllipsizedLayout(): StaticLayout {
124+
val paint = TextPaint().apply { textSize = 32f }
125+
val layout =
126+
StaticLayout.Builder.obtain(TAIL_TEXT, 0, TAIL_TEXT.length, paint, 400)
127+
.setMaxLines(1)
128+
.setEllipsize(TextUtils.TruncateAt.END)
129+
.build()
130+
assertThat(layout.lineCount).isEqualTo(1)
131+
assertThat(layout.getEllipsisCount(0)).isGreaterThan(0)
132+
return layout
133+
}
134+
135+
private companion object {
136+
const val TAIL_TEXT = "Hello\ndecorated world"
137+
}
53138
}

0 commit comments

Comments
 (0)