From 357f29dc7e004cd9284815d4e135be418b898f4d Mon Sep 17 00:00:00 2001 From: Andrew Ghostuhin Date: Tue, 9 Jun 2026 00:27:00 +0300 Subject: [PATCH] fix(android): prevent text descender clipping --- .../internal/span/CustomLineHeightSpan.kt | 9 ++- .../internal/span/CustomLineHeightSpanTest.kt | 56 +++++++++++++++++++ .../js/examples/Text/TextExample.android.js | 13 +++++ 3 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/internal/span/CustomLineHeightSpanTest.kt 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 5d30f853c3b2..dac3a6f6f0d3 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 000000000000..20fbc9eba8cb --- /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 090ced25e600..a88bef0bac79 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 +