diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/CustomLineHeightSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/CustomLineHeightSpan.kt
index 5d30f853c3b..dac3a6f6f0d 100644
--- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/CustomLineHeightSpan.kt
+++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/CustomLineHeightSpan.kt
@@ -11,6 +11,8 @@ import android.graphics.Paint.FontMetricsInt
import android.text.style.LineHeightSpan
import kotlin.math.ceil
import kotlin.math.floor
+import kotlin.math.max
+import kotlin.math.min
/**
* Implements a [LineHeightSpan] which follows web-like behavior for line height, unlike
@@ -28,6 +30,9 @@ internal class CustomLineHeightSpan(height: Float) : LineHeightSpan, ReactSpan {
v: Int,
fm: FontMetricsInt,
) {
+ val originalTop = fm.top
+ val originalBottom = fm.bottom
+
// https://www.w3.org/TR/css-inline-3/#inline-height
// When its computed line-height is not normal, its layout bounds are derived solely from
// metrics of its first available font (ignoring glyphs from other fonts), and leading is used
@@ -47,10 +52,10 @@ internal class CustomLineHeightSpan(height: Float) : LineHeightSpan, ReactSpan {
// line boxes to overlap (to allow too large glyphs to be drawn outside them), so we do not
// adjust the top/bottom of interior line-boxes.
if (start == 0) {
- fm.top = fm.ascent
+ fm.top = min(originalTop, fm.ascent)
}
if (end == text.length) {
- fm.bottom = fm.descent
+ fm.bottom = max(originalBottom, fm.descent)
}
}
}
diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/internal/span/CustomLineHeightSpanTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/internal/span/CustomLineHeightSpanTest.kt
new file mode 100644
index 00000000000..20fbc9eba8c
--- /dev/null
+++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/internal/span/CustomLineHeightSpanTest.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+package com.facebook.react.views.text.internal.span
+
+import android.graphics.Paint
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class CustomLineHeightSpanTest {
+
+ @Test
+ fun tightLineHeightDoesNotClipFirstOrLastLineFontBounds() {
+ val span = CustomLineHeightSpan(16f)
+ val fm =
+ Paint.FontMetricsInt().apply {
+ top = -18
+ ascent = -14
+ descent = 6
+ bottom = 8
+ }
+
+ span.chooseHeight("gjpqy", 0, 5, 0, 0, fm)
+
+ assertThat(fm.ascent).isEqualTo(-12)
+ assertThat(fm.descent).isEqualTo(4)
+ assertThat(fm.top).isEqualTo(-18)
+ assertThat(fm.bottom).isEqualTo(8)
+ }
+
+ @Test
+ fun looseLineHeightStillExpandsFirstAndLastLineBounds() {
+ val span = CustomLineHeightSpan(24f)
+ val fm =
+ Paint.FontMetricsInt().apply {
+ top = -18
+ ascent = -14
+ descent = 6
+ bottom = 8
+ }
+
+ span.chooseHeight("gjpqy", 0, 5, 0, 0, fm)
+
+ assertThat(fm.ascent).isEqualTo(-16)
+ assertThat(fm.descent).isEqualTo(8)
+ assertThat(fm.top).isEqualTo(-18)
+ assertThat(fm.bottom).isEqualTo(8)
+ }
+}
diff --git a/packages/rn-tester/js/examples/Text/TextExample.android.js b/packages/rn-tester/js/examples/Text/TextExample.android.js
index 090ced25e60..a88bef0bac7 100644
--- a/packages/rn-tester/js/examples/Text/TextExample.android.js
+++ b/packages/rn-tester/js/examples/Text/TextExample.android.js
@@ -1119,6 +1119,19 @@ function LineHeightExample(props: {}): React.Node {
Continually expedite
magnetic potentialities rather than client-focused interfaces.
+
+ gjpqy
+