From a15d5c5bff141e068aac4a2c9345a5ccd05c02ae Mon Sep 17 00:00:00 2001 From: Evan Katz Date: Mon, 3 Aug 2026 11:56:43 -0700 Subject: [PATCH 1/3] Add Android text font variation settings Summary: Add a `fontVariationSettings` text style prop and carry it through Fabric text attributes into Android text rendering. Android now deserializes the prop for ``, applies it to `Paint`, and includes it in text measurement cache identity because variable axes can affect layout. Preserve the distinction between an absent setting and an explicitly empty setting so nested text can inherit or clear the parent variation axes. Apply high-level font properties before low-level variation settings so explicit axes take precedence, matching CSS font realization order. Settings syntax is intentionally forwarded unchanged through common text attributes and validated only by the Android font variation parser. This avoids narrowing the grammar Android accepts. As a result, malformed child settings are outside the supported inheritance contract: they replace an inherited value before Android validation and are not guaranteed to fall back to the parent settings. Android also accepts `normal` and the React Native empty-string convention as explicit resets. Changelog: [Android][Added] - Add `fontVariationSettings` support for `` Differential Revision: D113580491 Reviewed By: Abbondanzo --- .../View/ReactNativeStyleAttributes.js | 1 + .../Libraries/StyleSheet/StyleSheetTypes.d.ts | 6 ++ .../Libraries/StyleSheet/StyleSheetTypes.js | 6 ++ .../ReactAndroid/api/ReactAndroid.api | 3 + .../com/facebook/react/uimanager/ViewProps.kt | 1 + .../react/views/text/ReactTypefaceUtils.kt | 43 +++++++++++ .../react/views/text/TextAttributeProps.kt | 10 +++ .../react/views/text/TextLayoutManager.kt | 7 ++ .../text/internal/span/CustomStyleSpan.kt | 5 ++ .../react/views/textinput/ReactEditText.kt | 4 +- .../views/text/TextAttributePropsTest.kt | 57 +++++++++++++++ ...xtLayoutManagerFontWeightAdjustmentTest.kt | 63 ++++++++++++++++ .../attributedstring/TextAttributes.cpp | 9 +++ .../attributedstring/TextAttributes.h | 2 + .../renderer/attributedstring/conversions.h | 4 ++ .../components/text/BaseTextProps.cpp | 20 ++++++ .../text/tests/BaseTextShadowNodeTest.cpp | 72 +++++++++++++++++++ .../textlayoutmanager/TextMeasureCache.h | 3 + .../tests/TextLayoutManagerTest.cpp | 33 +++++++++ packages/react-native/ReactNativeApi.d.ts | 17 ++--- .../api-snapshots/ReactAndroidDebugCxx.api | 2 + .../api-snapshots/ReactAndroidNewarchCxx.api | 2 + .../api-snapshots/ReactAndroidReleaseCxx.api | 2 + .../api-snapshots/ReactAppleDebugCxx.api | 1 + .../api-snapshots/ReactAppleNewarchCxx.api | 1 + .../api-snapshots/ReactAppleReleaseCxx.api | 1 + .../api-snapshots/ReactCommonDebugCxx.api | 1 + .../api-snapshots/ReactCommonNewarchCxx.api | 1 + .../api-snapshots/ReactCommonReleaseCxx.api | 1 + 29 files changed, 369 insertions(+), 9 deletions(-) diff --git a/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js b/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js index bb1e941b57af..68d4fa451f56 100644 --- a/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js +++ b/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js @@ -258,6 +258,7 @@ const ReactNativeStyleAttributes: {[string]: AnyAttributeType, ...} = { fontSize: true, fontStyle: true, fontVariant: fontVariantAttribute, + fontVariationSettings: true, fontWeight: true, includeFontPadding: true, letterSpacing: true, diff --git a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts index 80c004612b10..8e17f07e04fc 100644 --- a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts +++ b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts @@ -601,6 +601,12 @@ export interface TextStyle extends TextStyleIOS, TextStyleAndroid, ViewStyle { | 'heavy' | 'black' | undefined; + /** + * Specifies OpenType font variation axis values using CSS syntax. An empty + * string resets inherited variation settings. On Android, this requires API + * level 26 or later. + */ + fontVariationSettings?: string | undefined; letterSpacing?: number | undefined; lineHeight?: number | undefined; textAlign?: diff --git a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js index c04cfe1c4b20..9df143c6efe1 100644 --- a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js +++ b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js @@ -1011,6 +1011,12 @@ type ____TextStyle_InternalBase = Readonly<{ */ fontWeight?: ____FontWeight_Internal, fontVariant?: ____FontVariantArray_Internal | string, + /** + * Specifies OpenType font variation axis values using CSS syntax. An empty + * string resets inherited variation settings. On Android, this requires API + * level 26 or later. + */ + fontVariationSettings?: string, textShadowOffset?: Readonly<{ width: number, height: number, diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index f69f43bce001..02bfc4b552f0 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -4543,6 +4543,7 @@ public final class com/facebook/react/uimanager/ViewProps { public static final field FONT_SIZE Ljava/lang/String; public static final field FONT_STYLE Ljava/lang/String; public static final field FONT_VARIANT Ljava/lang/String; + public static final field FONT_VARIATION_SETTINGS Ljava/lang/String; public static final field FONT_WEIGHT Ljava/lang/String; public static final field FOREGROUND_COLOR Ljava/lang/String; public static final field GAP Ljava/lang/String; @@ -6140,6 +6141,7 @@ public final class com/facebook/react/views/text/TextAttributeProps { public static final field TA_KEY_FONT_SIZE_MULTIPLIER I public static final field TA_KEY_FONT_STYLE I public static final field TA_KEY_FONT_VARIANT I + public static final field TA_KEY_FONT_VARIATION_SETTINGS I public static final field TA_KEY_FONT_WEIGHT I public static final field TA_KEY_FOREGROUND_COLOR I public static final field TA_KEY_IS_HIGHLIGHTED I @@ -6170,6 +6172,7 @@ public final class com/facebook/react/views/text/TextAttributeProps { public final fun getFontFeatureSettings ()Ljava/lang/String; public final fun getFontSize ()I public final fun getFontStyle ()I + public final fun getFontVariationSettings ()Ljava/lang/String; public final fun getFontWeight ()I public final fun getLayoutDirection ()I public final fun getLetterSpacing ()F diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt index 23c4b0a97443..07d7e132e7d6 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt @@ -84,6 +84,7 @@ public object ViewProps { public const val FONT_WEIGHT: String = "fontWeight" public const val FONT_STYLE: String = "fontStyle" public const val FONT_VARIANT: String = "fontVariant" + public const val FONT_VARIATION_SETTINGS: String = "fontVariationSettings" public const val FONT_FAMILY: String = "fontFamily" public const val LINE_HEIGHT: String = "lineHeight" public const val LETTER_SPACING: String = "letterSpacing" diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTypefaceUtils.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTypefaceUtils.kt index 71dc93dda720..df6eb1dd880f 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTypefaceUtils.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTypefaceUtils.kt @@ -10,8 +10,11 @@ package com.facebook.react.views.text import android.content.Context import android.content.res.AssetManager import android.content.res.Configuration +import android.graphics.Paint import android.graphics.Typeface +import android.graphics.fonts.FontVariationAxis import android.os.Build +import com.facebook.common.logging.FLog import com.facebook.react.bridge.ReadableArray import com.facebook.react.common.ReactConstants import com.facebook.react.common.assets.ReactFontManager @@ -151,4 +154,44 @@ public object ReactTypefaceUtils { return Typeface.create(baseTypeface, adjustedWeight, italic) } + + internal fun parseFontVariationSettings(fontVariationSettings: String?): String? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return null + } + + if (fontVariationSettings.isNullOrEmpty()) { + return fontVariationSettings + } + + if (fontVariationSettings.trim().equals("normal", ignoreCase = true)) { + return "" + } + + return try { + FontVariationAxis.fromFontVariationSettings(fontVariationSettings) + fontVariationSettings + } catch (exception: IllegalArgumentException) { + FLog.w(ReactConstants.TAG, "Invalid fontVariationSettings: $fontVariationSettings") + null + } + } + + internal fun applyFontVariationSettings(paint: Paint, fontVariationSettings: String?) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + + try { + paint.setFontVariationSettings(fontVariationSettings) + } catch (exception: IllegalArgumentException) { + // Paint instances are reused, so explicitly clear axes from a previous layout. + paint.setFontVariationSettings(null) + FLog.w( + ReactConstants.TAG, + "Invalid fontVariationSettings: $fontVariationSettings", + exception, + ) + } + } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.kt index 7ad338167d5e..4ebaa5630c09 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.kt @@ -25,6 +25,7 @@ import com.facebook.react.uimanager.ReactStylesDiffMap import com.facebook.react.uimanager.ViewProps import com.facebook.react.views.text.ReactTypefaceUtils.parseFontStyle import com.facebook.react.views.text.ReactTypefaceUtils.parseFontVariant +import com.facebook.react.views.text.ReactTypefaceUtils.parseFontVariationSettings import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight import kotlin.math.ceil @@ -153,6 +154,10 @@ public class TextAttributeProps private constructor() { public var fontFeatureSettings: String? = null private set + /** @see android.graphics.Paint.setFontVariationSettings */ + public var fontVariationSettings: String? = null + private set + @Deprecated("Use lineHeight instead", ReplaceWith("lineHeight")) public val effectiveLineHeight: Float get() = lineHeight @@ -396,6 +401,7 @@ public class TextAttributeProps private constructor() { public const val TA_KEY_TEXT_TRANSFORM: Int = 27 public const val TA_KEY_MAX_FONT_SIZE_MULTIPLIER: Int = 29 public const val TA_KEY_TEXT_EFFECTS: Int = 30 + public const val TA_KEY_FONT_VARIATION_SETTINGS: Int = 31 private const val TE_KEY_NAME: Int = 0 private const val TE_KEY_PROPS: Int = 1 @@ -433,6 +439,8 @@ public class TextAttributeProps private constructor() { TA_KEY_FONT_WEIGHT -> result.setFontWeight(entry.stringValue) TA_KEY_FONT_STYLE -> result.setFontStyle(entry.stringValue) TA_KEY_FONT_VARIANT -> result.setFontVariant(entry.mapBufferValue) + TA_KEY_FONT_VARIATION_SETTINGS -> + result.fontVariationSettings = parseFontVariationSettings(entry.stringValue) TA_KEY_ALLOW_FONT_SCALING -> result.allowFontScaling = entry.booleanValue TA_KEY_LETTER_SPACING -> result.letterSpacing = entry.doubleValue.toFloat() TA_KEY_LINE_HEIGHT -> result.lineHeight = entry.doubleValue.toFloat() @@ -500,6 +508,8 @@ public class TextAttributeProps private constructor() { result.setFontWeight(getStringProp(props, ViewProps.FONT_WEIGHT)) result.setFontStyle(getStringProp(props, ViewProps.FONT_STYLE)) result.setFontVariant(getArrayProp(props, ViewProps.FONT_VARIANT)) + result.fontVariationSettings = + parseFontVariationSettings(getStringProp(props, ViewProps.FONT_VARIATION_SETTINGS)) result.includeFontPadding = getBooleanProp(props, ViewProps.INCLUDE_FONT_PADDING, true) result.setTextDecorationLine(getStringProp(props, ViewProps.TEXT_DECORATION_LINE)) result.textDecorationColor = diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt index e64f5de40759..5e0f6575c6dc 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt @@ -313,6 +313,7 @@ internal object TextLayoutManager { if ( textAttributes.fontStyle != ReactConstants.UNSET || textAttributes.fontWeight != ReactConstants.UNSET || + textAttributes.fontVariationSettings != null || textAttributes.fontFamily != null ) { ops.add( @@ -323,6 +324,7 @@ internal object TextLayoutManager { textAttributes.fontStyle, textAttributes.fontWeight, textAttributes.fontFeatureSettings, + textAttributes.fontVariationSettings, textAttributes.fontFamily, assets, fontWeightAdjustment, @@ -547,6 +549,7 @@ internal object TextLayoutManager { if ( fragment.props.fontStyle != ReactConstants.UNSET || fragment.props.fontWeight != ReactConstants.UNSET || + fragment.props.fontVariationSettings != null || fragment.props.fontFamily != null ) { spannable.setSpan( @@ -554,6 +557,7 @@ internal object TextLayoutManager { fragment.props.fontStyle, fragment.props.fontWeight, fragment.props.fontFeatureSettings, + fragment.props.fontVariationSettings, fragment.props.fontFamily, assets, fontWeightAdjustment, @@ -883,6 +887,7 @@ internal object TextLayoutManager { if ( baseTextAttributes.fontStyle != ReactConstants.UNSET || baseTextAttributes.fontWeight != ReactConstants.UNSET || + baseTextAttributes.fontVariationSettings != null || baseTextAttributes.fontFamily != null ) { val typeface = @@ -912,6 +917,8 @@ internal object TextLayoutManager { paint.setTypeface(typeface) } } + + ReactTypefaceUtils.applyFontVariationSettings(paint, baseTextAttributes.fontVariationSettings) } /** diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/CustomStyleSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/CustomStyleSpan.kt index 29eb30f14470..a0607b012be6 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/CustomStyleSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/CustomStyleSpan.kt @@ -31,6 +31,7 @@ internal class CustomStyleSpan( private val privateStyle: Int, private val privateWeight: Int, val fontFeatureSettings: String?, + val fontVariationSettings: String?, val fontFamily: String?, private val assetManager: AssetManager, private val fontWeightAdjustment: Int = 0, @@ -41,6 +42,7 @@ internal class CustomStyleSpan( privateStyle, privateWeight, fontFeatureSettings, + fontVariationSettings, fontFamily, assetManager, fontWeightAdjustment, @@ -53,6 +55,7 @@ internal class CustomStyleSpan( privateStyle, privateWeight, fontFeatureSettings, + fontVariationSettings, fontFamily, assetManager, fontWeightAdjustment, @@ -81,6 +84,7 @@ internal class CustomStyleSpan( style: Int, weight: Int, fontFeatureSettingsParam: String?, + fontVariationSettingsParam: String?, family: String?, assetManager: AssetManager, fontWeightAdjustment: Int, @@ -92,6 +96,7 @@ internal class CustomStyleSpan( paint.apply { fontFeatureSettings = fontFeatureSettingsParam setTypeface(adjustedTypeface) + ReactTypefaceUtils.applyFontVariationSettings(this, fontVariationSettingsParam) isSubpixelText = true isLinearText = true } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt index 50dea1239a40..fd9e3f123d8f 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt @@ -826,7 +826,8 @@ public open class ReactEditText public constructor(context: Context) : AppCompat span.style == fontStyle && span.fontFamily == fontFamily && span.weight == fontWeight && - span.fontFeatureSettings == fontFeatureSettings + span.fontFeatureSettings == fontFeatureSettings && + span.fontVariationSettings == null } } @@ -908,6 +909,7 @@ public open class ReactEditText public constructor(context: Context) : AppCompat fontStyle, fontWeight, fontFeatureSettings, + null, fontFamily, context.assets, getFontWeightAdjustment(context), diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextAttributePropsTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextAttributePropsTest.kt index 0b63ff69a2a6..81d269ffd167 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextAttributePropsTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextAttributePropsTest.kt @@ -9,12 +9,69 @@ package com.facebook.react.views.text import android.view.Gravity import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.uimanager.DisplayMetricsHolder import com.facebook.react.uimanager.ReactStylesDiffMap import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +@RunWith(RobolectricTestRunner::class) class TextAttributePropsTest { + @Before + fun setUp() { + DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(RuntimeEnvironment.getApplication()) + } + + @After + fun tearDown() { + DisplayMetricsHolder.setScreenDisplayMetrics(null) + } + + @Test + fun readableMapSetsFontVariationSettings() { + val textAttributes = + TextAttributeProps.fromReadableMap( + ReactStylesDiffMap(JavaOnlyMap.of("fontVariationSettings", "'wght' 550")) + ) + + assertThat(textAttributes.fontVariationSettings).isEqualTo("'wght' 550") + } + + @Test + fun readableMapSetsDoubleQuotedFontVariationSettings() { + val textAttributes = + TextAttributeProps.fromReadableMap( + ReactStylesDiffMap(JavaOnlyMap.of("fontVariationSettings", "\"wght\" 450")) + ) + + assertThat(textAttributes.fontVariationSettings).isEqualTo("\"wght\" 450") + } + + @Test + fun readableMapIgnoresInvalidFontVariationSettings() { + val textAttributes = + TextAttributeProps.fromReadableMap( + ReactStylesDiffMap(JavaOnlyMap.of("fontVariationSettings", "invalid")) + ) + + assertThat(textAttributes.fontVariationSettings).isNull() + } + + @Test + fun readableMapTreatsNormalFontVariationSettingsAsExplicitReset() { + val textAttributes = + TextAttributeProps.fromReadableMap( + ReactStylesDiffMap(JavaOnlyMap.of("fontVariationSettings", "NoRmAl")) + ) + + assertThat(textAttributes.fontVariationSettings).isEmpty() + } + @Test fun textAlignStartUsesStartSide() { assertThat(textAlignment("start", isRTL = false)).isEqualTo(Gravity.LEFT) diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerFontWeightAdjustmentTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerFontWeightAdjustmentTest.kt index 3cc6751e6e0b..cfa95850489c 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerFontWeightAdjustmentTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerFontWeightAdjustmentTest.kt @@ -7,19 +7,30 @@ package com.facebook.react.views.text +import android.graphics.Paint +import android.graphics.Typeface +import android.os.Build import android.text.TextPaint +import androidx.annotation.RequiresApi import com.facebook.react.bridge.JavaOnlyMap import com.facebook.react.uimanager.DisplayMetricsHolder import com.facebook.react.uimanager.ReactStylesDiffMap +import com.facebook.react.views.text.internal.span.CustomStyleSpan import org.assertj.core.api.Assertions.assertThat import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config +@RequiresApi(Build.VERSION_CODES.O) @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) class TextLayoutManagerFontWeightAdjustmentTest { @@ -64,6 +75,58 @@ class TextLayoutManagerFontWeightAdjustmentTest { assertThat(paint.typeface).isNull() } + @Test + fun `plain text paint ignores invalid font variation settings`() { + val paint = TextPaint(TextPaint.ANTI_ALIAS_FLAG) + val textAttributes = + TextAttributeProps.fromReadableMap( + ReactStylesDiffMap(JavaOnlyMap.of("fontVariationSettings", "invalid")) + ) + + TextLayoutManager.updateTextPaint( + paint, + textAttributes, + RuntimeEnvironment.getApplication().assets, + 0, + ) + + assertThat(paint.fontVariationSettings).isNull() + } + + @Test + fun `custom style applies font variation settings after high level font properties`() { + val paint = mock() + val span = CustomStyleSpan( + Typeface.NORMAL, + 700, + null, + "'wght' 450", + "sans-serif", + RuntimeEnvironment.getApplication().assets, + ) + + span.updateMeasureState(paint) + + inOrder(paint) { + verify(paint).setTypeface(any()) + verify(paint).setFontVariationSettings("'wght' 450") + } + } + + @Test + fun `invalid font variation settings defensively clear a reused paint`() { + val paint = mock() + val invalidSettings = "invalid-reused-paint-test" + whenever(paint.setFontVariationSettings(invalidSettings)).thenThrow(IllegalArgumentException()) + + ReactTypefaceUtils.applyFontVariationSettings(paint, "'wght' 700") + ReactTypefaceUtils.applyFontVariationSettings(paint, invalidSettings) + + verify(paint).setFontVariationSettings("'wght' 700") + verify(paint).setFontVariationSettings(invalidSettings) + verify(paint).setFontVariationSettings(null) + } + private companion object { const val FONT_WEIGHT_ADJUSTMENT_BOLD_TEXT = 300 } diff --git a/packages/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.cpp b/packages/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.cpp index be2e1a2d481b..5851ec21dfff 100644 --- a/packages/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.cpp +++ b/packages/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.cpp @@ -43,6 +43,9 @@ void TextAttributes::apply(TextAttributes textAttributes) { fontVariant = textAttributes.fontVariant.has_value() ? textAttributes.fontVariant : fontVariant; + fontVariationSettings = textAttributes.fontVariationSettings.has_value() + ? textAttributes.fontVariationSettings + : fontVariationSettings; allowFontScaling = textAttributes.allowFontScaling.has_value() ? textAttributes.allowFontScaling : allowFontScaling; @@ -128,6 +131,7 @@ bool TextAttributes::operator==(const TextAttributes& rhs) const { fontWeight, fontStyle, fontVariant, + fontVariationSettings, allowFontScaling, dynamicTypeRamp, alignment, @@ -152,6 +156,7 @@ bool TextAttributes::operator==(const TextAttributes& rhs) const { rhs.fontWeight, rhs.fontStyle, rhs.fontVariant, + rhs.fontVariationSettings, rhs.allowFontScaling, rhs.dynamicTypeRamp, rhs.alignment, @@ -218,6 +223,10 @@ SharedDebugStringConvertibleList TextAttributes::getDebugProps() const { "fontStyle", fontStyle, textAttributes.fontStyle), debugStringConvertibleItem( "fontVariant", fontVariant, textAttributes.fontVariant), + debugStringConvertibleItem( + "fontVariationSettings", + fontVariationSettings, + textAttributes.fontVariationSettings), debugStringConvertibleItem( "allowFontScaling", allowFontScaling, diff --git a/packages/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.h b/packages/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.h index c162deee2f73..683c993bf968 100644 --- a/packages/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.h +++ b/packages/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.h @@ -58,6 +58,7 @@ class TextAttributes : public DebugStringConvertible { std::optional fontWeight{}; std::optional fontStyle{}; std::optional fontVariant{}; + std::optional fontVariationSettings{}; std::optional allowFontScaling{}; Float maxFontSizeMultiplier{std::numeric_limits::quiet_NaN()}; std::optional dynamicTypeRamp{}; @@ -143,6 +144,7 @@ struct hash { textAttributes.fontWeight, textAttributes.fontStyle, textAttributes.fontVariant, + textAttributes.fontVariationSettings, textAttributes.allowFontScaling, textAttributes.letterSpacing, textAttributes.textTransform, diff --git a/packages/react-native/ReactCommon/react/renderer/attributedstring/conversions.h b/packages/react-native/ReactCommon/react/renderer/attributedstring/conversions.h index 235e966ae131..beed96737801 100644 --- a/packages/react-native/ReactCommon/react/renderer/attributedstring/conversions.h +++ b/packages/react-native/ReactCommon/react/renderer/attributedstring/conversions.h @@ -1142,6 +1142,7 @@ constexpr static MapBuffer::Key TA_KEY_TEXT_TRANSFORM = 27; constexpr static MapBuffer::Key TA_KEY_ALIGNMENT_VERTICAL = 28; constexpr static MapBuffer::Key TA_KEY_MAX_FONT_SIZE_MULTIPLIER = 29; constexpr static MapBuffer::Key TA_KEY_TEXT_EFFECTS = 30; +constexpr static MapBuffer::Key TA_KEY_FONT_VARIATION_SETTINGS = 31; // Keys within each text effect entry MapBuffer constexpr static MapBuffer::Key TE_KEY_NAME = 0; @@ -1290,6 +1291,9 @@ inline MapBuffer toMapBuffer(const TextAttributes &textAttributes) auto fontVariantMap = toMapBuffer(*textAttributes.fontVariant); builder.putMapBuffer(TA_KEY_FONT_VARIANT, fontVariantMap); } + if (textAttributes.fontVariationSettings.has_value()) { + builder.putString(TA_KEY_FONT_VARIATION_SETTINGS, *textAttributes.fontVariationSettings); + } if (textAttributes.allowFontScaling.has_value()) { builder.putBool(TA_KEY_ALLOW_FONT_SCALING, *textAttributes.allowFontScaling); } diff --git a/packages/react-native/ReactCommon/react/renderer/components/text/BaseTextProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/text/BaseTextProps.cpp index 51fd245e4209..135463aa2d09 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/text/BaseTextProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/text/BaseTextProps.cpp @@ -67,6 +67,12 @@ static TextAttributes convertRawProp( "fontVariant", sourceTextAttributes.fontVariant, defaultTextAttributes.fontVariant); + textAttributes.fontVariationSettings = convertRawProp( + context, + rawProps, + "fontVariationSettings", + sourceTextAttributes.fontVariationSettings, + defaultTextAttributes.fontVariationSettings); textAttributes.allowFontScaling = convertRawProp( context, rawProps, @@ -261,6 +267,12 @@ void BaseTextProps::setProp( defaults, value, textAttributes, fontStyle, "fontStyle"); REBUILD_FIELD_SWITCH_CASE( defaults, value, textAttributes, fontVariant, "fontVariant"); + REBUILD_FIELD_SWITCH_CASE( + defaults, + value, + textAttributes, + fontVariationSettings, + "fontVariationSettings"); REBUILD_FIELD_SWITCH_CASE( defaults, value, textAttributes, allowFontScaling, "allowFontScaling"); REBUILD_FIELD_SWITCH_CASE( @@ -393,6 +405,14 @@ void BaseTextProps::appendTextAttributesProps( : folly::dynamic(nullptr); } + if (textAttributes.fontVariationSettings != + oldProps->textAttributes.fontVariationSettings) { + result["fontVariationSettings"] = + textAttributes.fontVariationSettings.has_value() + ? folly::dynamic(*textAttributes.fontVariationSettings) + : folly::dynamic(nullptr); + } + if (textAttributes.allowFontScaling != oldProps->textAttributes.allowFontScaling) { result["allowFontScaling"] = textAttributes.allowFontScaling.has_value() diff --git a/packages/react-native/ReactCommon/react/renderer/components/text/tests/BaseTextShadowNodeTest.cpp b/packages/react-native/ReactCommon/react/renderer/components/text/tests/BaseTextShadowNodeTest.cpp index 560f2e661e84..0d4ad930c520 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/text/tests/BaseTextShadowNodeTest.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/text/tests/BaseTextShadowNodeTest.cpp @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -32,6 +33,43 @@ std::string roundTripTextAlignment(const char* textAlignment) { return toString(result); } +TextAttributes nestedTextAttributes( + const std::shared_ptr& parentProps, + const std::shared_ptr& childProps) { + auto builder = simpleComponentBuilder(); + auto shadowNode = builder.build( + Element().children({ + Element() + .props(parentProps) + .children({ + Element() + .props(childProps) + .children({rawTextElement("Text")}), + }), + })); + + auto baseTextAttributes = TextAttributes::defaultTextAttributes(); + AttributedString output; + BaseTextShadowNode::Attachments attachments; + BaseTextShadowNode::buildAttributedString( + baseTextAttributes, *shadowNode, output, attachments); + + EXPECT_EQ(output.getFragments().size(), 1); + return output.getFragments()[0].textAttributes; +} + +std::shared_ptr textPropsWithRawFontVariationSettings( + const char* fontVariationSettings) { + ContextContainer contextContainer{}; + PropsParserContext parserContext{-1, contextContainer}; + auto rawProps = RawProps( + folly::dynamic::object("fontVariationSettings", fontVariationSettings)); + auto rawPropsParser = RawPropsParser{}; + rawPropsParser.prepare(); + rawProps.parse(rawPropsParser); + return std::make_shared(parserContext, TextProps{}, rawProps); +} + } // namespace TEST(BaseTextShadowNodeTest, textAlignmentStartAndEndRoundTrip) { @@ -107,4 +145,38 @@ TEST(BaseTextShadowNodeTest, rawTextIsMerged) { EXPECT_EQ(output.getFragments().size(), 1); } +TEST(BaseTextShadowNodeTest, childInheritsParentFontVariationSettings) { + auto parentProps = std::make_shared(); + parentProps->textAttributes.fontVariationSettings = "'wght' 650"; + auto childProps = std::make_shared(); + + const auto attributes = nestedTextAttributes(parentProps, childProps); + + EXPECT_EQ(attributes.fontVariationSettings, "'wght' 650"); +} + +TEST(BaseTextShadowNodeTest, childFontVariationSettingsReplaceParentList) { + auto parentProps = std::make_shared(); + parentProps->textAttributes.fontVariationSettings = "'wght' 650, 'wdth' 90"; + auto childProps = textPropsWithRawFontVariationSettings("'wdth' 110"); + + const auto attributes = nestedTextAttributes(parentProps, childProps); + + EXPECT_EQ(attributes.fontVariationSettings, "'wdth' 110"); +} + +TEST( + BaseTextShadowNodeTest, + childFontWeightCoexistsWithInheritedFontVariationSettings) { + auto parentProps = std::make_shared(); + parentProps->textAttributes.fontVariationSettings = "'wght' 650"; + auto childProps = std::make_shared(); + childProps->textAttributes.fontWeight = FontWeight::Weight400; + + const auto attributes = nestedTextAttributes(parentProps, childProps); + + EXPECT_EQ(attributes.fontWeight, FontWeight::Weight400); + EXPECT_EQ(attributes.fontVariationSettings, "'wght' 650"); +} + } // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/TextMeasureCache.h b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/TextMeasureCache.h index f062132f2741..e302e5f7dded 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/TextMeasureCache.h +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/TextMeasureCache.h @@ -125,6 +125,7 @@ inline bool areTextAttributesEquivalentLayoutWise(const TextAttributes &lhs, con lhs.fontWeight, lhs.fontStyle, lhs.fontVariant, + lhs.fontVariationSettings, lhs.allowFontScaling, lhs.dynamicTypeRamp, lhs.alignment) == @@ -133,6 +134,7 @@ inline bool areTextAttributesEquivalentLayoutWise(const TextAttributes &lhs, con rhs.fontWeight, rhs.fontStyle, rhs.fontVariant, + rhs.fontVariationSettings, rhs.allowFontScaling, rhs.dynamicTypeRamp, rhs.alignment) && @@ -152,6 +154,7 @@ inline size_t textAttributesHashLayoutWise(const TextAttributes &textAttributes) textAttributes.fontWeight, textAttributes.fontStyle, textAttributes.fontVariant, + textAttributes.fontVariationSettings, textAttributes.allowFontScaling, textAttributes.maxFontSizeMultiplier, textAttributes.dynamicTypeRamp, diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/tests/TextLayoutManagerTest.cpp b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/tests/TextLayoutManagerTest.cpp index d19545a83f67..3b687657917b 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/tests/TextLayoutManagerTest.cpp +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/tests/TextLayoutManagerTest.cpp @@ -48,6 +48,39 @@ TEST(TextLayoutManagerTest, maxFontSizeMultiplierAffectsLayoutCacheHash) { textAttributesHashLayoutWise(lhs), textAttributesHashLayoutWise(rhs)); } +TEST(TextLayoutManagerTest, fontVariationSettingsAffectLayoutCacheEquality) { + TextAttributes lhs; + TextAttributes rhs; + + lhs.fontVariationSettings = "'wght' 400"; + rhs.fontVariationSettings = "'wght' 700"; + + EXPECT_FALSE(areTextAttributesEquivalentLayoutWise(lhs, rhs)); +} + +TEST(TextLayoutManagerTest, fontVariationSettingsAffectLayoutCacheHash) { + TextAttributes lhs; + TextAttributes rhs; + + lhs.fontVariationSettings = "'wght' 400"; + rhs.fontVariationSettings = "'wght' 700"; + + EXPECT_NE( + textAttributesHashLayoutWise(lhs), textAttributesHashLayoutWise(rhs)); +} + +TEST(TextLayoutManagerTest, emptyFontVariationSettingsClearInheritedSettings) { + TextAttributes parent; + TextAttributes child; + + parent.fontVariationSettings = "'wght' 700"; + child.fontVariationSettings = ""; + parent.apply(child); + + EXPECT_TRUE(parent.fontVariationSettings.has_value()); + EXPECT_TRUE(parent.fontVariationSettings->empty()); +} + // Measurements are rounded to the pixel grid, so a measurement cached at one // pixel scale factor must not satisfy a lookup at another. Keys that differ // only by pointScaleFactor must compare unequal. diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 24f632825a84..b895f37a3ff0 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -744,6 +744,7 @@ declare type ____TextStyle_InternalBase = { readonly fontSize?: number readonly fontStyle?: "italic" | "normal" readonly fontVariant?: ____FontVariantArray_Internal | string + readonly fontVariationSettings?: string readonly fontWeight?: ____FontWeight_Internal readonly includeFontPadding?: boolean readonly letterSpacing?: number @@ -5714,7 +5715,7 @@ export { AlertOptions, // 8a116d2a AlertType, // 5ab91217 AndroidKeyboardEvent, // e03becc8 - Animated, // 50cbe576 + Animated, // 4bf77b70 AppConfig, // 35c0ca70 AppRegistry, // 1e8c5a00 AppState, // 12012be5 @@ -5937,7 +5938,7 @@ export { StatusBarProps, // c2a44d88 StatusBarStyle, // 78f53eea StyleProp, // fa0e9b4a - StyleSheet, // e734acd4 + StyleSheet, // 3c21ec63 SubmitBehavior, // c4ddf490 Switch, // f495bab3 SwitchChangeEvent, // 899635b1 @@ -5947,9 +5948,9 @@ export { TVViewPropsIOS, // 330ce7b5 TargetedEvent, // 16e98910 TaskProvider, // 266dedf2 - Text, // 9ceaff08 + Text, // 09e783b9 TextContentType, // 239b3ecc - TextInput, // ce1e41ff + TextInput, // a2cc82d1 TextInputAndroidProps, // 9ebbc103 TextInputBlurEvent, // b77af40e TextInputChangeEvent, // f55eef98 @@ -5959,13 +5960,13 @@ export { TextInputIOSProps, // fb3c9327 TextInputInstance, // 5a0c0e0d TextInputKeyPressEvent, // 546c5d07 - TextInputProps, // c692f4de + TextInputProps, // dd91ebaa TextInputSelectionChangeEvent, // e58f2abc TextInputSubmitEditingEvent, // 6bcb2aa5 TextInstance, // 05463a96 TextLayoutEvent, // 3f54186f - TextProps, // 93309fac - TextStyle, // b62b8399 + TextProps, // 4a122c2b + TextStyle, // 6ad4307f ToastAndroid, // 88a8969a TouchableHighlight, // 3a6eaed4 TouchableHighlightInstance, // b510c0eb diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api index dff12a0398b3..c46ce0ae25dc 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api @@ -523,6 +523,7 @@ static constexpr facebook::react::MapBuffer::Key facebook::react::TA_KEY_FONT_SI static constexpr facebook::react::MapBuffer::Key facebook::react::TA_KEY_FONT_SIZE_MULTIPLIER; static constexpr facebook::react::MapBuffer::Key facebook::react::TA_KEY_FONT_STYLE; static constexpr facebook::react::MapBuffer::Key facebook::react::TA_KEY_FONT_VARIANT; +static constexpr facebook::react::MapBuffer::Key facebook::react::TA_KEY_FONT_VARIATION_SETTINGS; static constexpr facebook::react::MapBuffer::Key facebook::react::TA_KEY_FONT_WEIGHT; static constexpr facebook::react::MapBuffer::Key facebook::react::TA_KEY_FOREGROUND_COLOR; static constexpr facebook::react::MapBuffer::Key facebook::react::TA_KEY_IS_HIGHLIGHTED; @@ -5011,6 +5012,7 @@ class facebook::react::TextAttributes : public facebook::react::DebugStringConve public std::optional textDecorationStyle; public std::optional textTransform; public std::optional baseWritingDirection; + public std::optional fontVariationSettings; public std::string fontFamily; public std::vector textEffects; public void apply(facebook::react::TextAttributes textAttributes); diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api index bae821b61ec2..9aaf7be6052a 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api @@ -523,6 +523,7 @@ static constexpr facebook::react::MapBuffer::Key facebook::react::TA_KEY_FONT_SI static constexpr facebook::react::MapBuffer::Key facebook::react::TA_KEY_FONT_SIZE_MULTIPLIER; static constexpr facebook::react::MapBuffer::Key facebook::react::TA_KEY_FONT_STYLE; static constexpr facebook::react::MapBuffer::Key facebook::react::TA_KEY_FONT_VARIANT; +static constexpr facebook::react::MapBuffer::Key facebook::react::TA_KEY_FONT_VARIATION_SETTINGS; static constexpr facebook::react::MapBuffer::Key facebook::react::TA_KEY_FONT_WEIGHT; static constexpr facebook::react::MapBuffer::Key facebook::react::TA_KEY_FOREGROUND_COLOR; static constexpr facebook::react::MapBuffer::Key facebook::react::TA_KEY_IS_HIGHLIGHTED; @@ -4822,6 +4823,7 @@ class facebook::react::TextAttributes : public facebook::react::DebugStringConve public std::optional textDecorationStyle; public std::optional textTransform; public std::optional baseWritingDirection; + public std::optional fontVariationSettings; public std::string fontFamily; public std::vector textEffects; public void apply(facebook::react::TextAttributes textAttributes); diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api index beb96517ca4e..2dcd7df641bf 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api @@ -523,6 +523,7 @@ static constexpr facebook::react::MapBuffer::Key facebook::react::TA_KEY_FONT_SI static constexpr facebook::react::MapBuffer::Key facebook::react::TA_KEY_FONT_SIZE_MULTIPLIER; static constexpr facebook::react::MapBuffer::Key facebook::react::TA_KEY_FONT_STYLE; static constexpr facebook::react::MapBuffer::Key facebook::react::TA_KEY_FONT_VARIANT; +static constexpr facebook::react::MapBuffer::Key facebook::react::TA_KEY_FONT_VARIATION_SETTINGS; static constexpr facebook::react::MapBuffer::Key facebook::react::TA_KEY_FONT_WEIGHT; static constexpr facebook::react::MapBuffer::Key facebook::react::TA_KEY_FOREGROUND_COLOR; static constexpr facebook::react::MapBuffer::Key facebook::react::TA_KEY_IS_HIGHLIGHTED; @@ -5002,6 +5003,7 @@ class facebook::react::TextAttributes : public facebook::react::DebugStringConve public std::optional textDecorationStyle; public std::optional textTransform; public std::optional baseWritingDirection; + public std::optional fontVariationSettings; public std::string fontFamily; public std::vector textEffects; public void apply(facebook::react::TextAttributes textAttributes); diff --git a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api index c27595d7f77a..08edbf9b8886 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -7202,6 +7202,7 @@ class facebook::react::TextAttributes : public facebook::react::DebugStringConve public std::optional textDecorationStyle; public std::optional textTransform; public std::optional baseWritingDirection; + public std::optional fontVariationSettings; public std::string fontFamily; public std::vector textEffects; public void apply(facebook::react::TextAttributes textAttributes); diff --git a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api index c8a738e9069d..381fe9367c80 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api @@ -7041,6 +7041,7 @@ class facebook::react::TextAttributes : public facebook::react::DebugStringConve public std::optional textDecorationStyle; public std::optional textTransform; public std::optional baseWritingDirection; + public std::optional fontVariationSettings; public std::string fontFamily; public std::vector textEffects; public void apply(facebook::react::TextAttributes textAttributes); diff --git a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api index 3d09b6d29173..fe6bc0e042b4 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api @@ -7193,6 +7193,7 @@ class facebook::react::TextAttributes : public facebook::react::DebugStringConve public std::optional textDecorationStyle; public std::optional textTransform; public std::optional baseWritingDirection; + public std::optional fontVariationSettings; public std::string fontFamily; public std::vector textEffects; public void apply(facebook::react::TextAttributes textAttributes); diff --git a/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api index 0c7d0a5b8a1c..82f34060bfaf 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api @@ -3525,6 +3525,7 @@ class facebook::react::TextAttributes : public facebook::react::DebugStringConve public std::optional textDecorationStyle; public std::optional textTransform; public std::optional baseWritingDirection; + public std::optional fontVariationSettings; public std::string fontFamily; public std::vector textEffects; public void apply(facebook::react::TextAttributes textAttributes); diff --git a/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api index 4b6b80e8c2b8..26ed4473aab4 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api @@ -3376,6 +3376,7 @@ class facebook::react::TextAttributes : public facebook::react::DebugStringConve public std::optional textDecorationStyle; public std::optional textTransform; public std::optional baseWritingDirection; + public std::optional fontVariationSettings; public std::string fontFamily; public std::vector textEffects; public void apply(facebook::react::TextAttributes textAttributes); diff --git a/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api index a0e1c10862b6..ec7d627bb4e2 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api @@ -3516,6 +3516,7 @@ class facebook::react::TextAttributes : public facebook::react::DebugStringConve public std::optional textDecorationStyle; public std::optional textTransform; public std::optional baseWritingDirection; + public std::optional fontVariationSettings; public std::string fontFamily; public std::vector textEffects; public void apply(facebook::react::TextAttributes textAttributes); From 29244f18a706baee0103b2120c0fe3012ce4a8e1 Mon Sep 17 00:00:00 2001 From: Evan Katz Date: Mon, 3 Aug 2026 11:56:44 -0700 Subject: [PATCH 2/3] Add font variation settings support to TextInput Summary: Extend Android `TextInput` support for the existing `fontVariationSettings` text style prop. Fabric now forwards changes and resets through `AndroidTextInputProps`, and `ReactTextInputManager` applies the normalized value to the underlying `ReactEditText` paint after typeface updates. Using the `EditText` paint as the source of truth applies variable font axes consistently to placeholders, controlled values, and newly typed text. Equivalent `CustomStyleSpan`s are stripped while spans with different variation settings remain intact for attributed text ranges. As with ``, common text attributes forward settings syntax unchanged and Android remains the validation authority. This avoids narrowing Android-compatible grammar; malformed settings are outside the supported inheritance contract. Both `normal` and the React Native empty-string convention explicitly reset variation settings. Changelog: [Android][Added] - Add `fontVariationSettings` support for `TextInput` Differential Revision: D113580493 --- .../AndroidTextInputNativeComponent.js | 2 + .../ReactAndroid/api/ReactAndroid.api | 1 + .../react/views/textinput/ReactEditText.kt | 35 +++++-- .../views/textinput/ReactTextInputManager.kt | 5 + .../textinput/ReactTextInputPropertyTest.kt | 94 +++++++++++++++++++ .../AndroidTextInputProps.cpp | 12 +++ 6 files changed, 143 insertions(+), 6 deletions(-) diff --git a/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js b/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js index b0cf8dafd2d2..680ff7aa974b 100644 --- a/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js +++ b/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js @@ -615,6 +615,7 @@ export type AndroidTextInputNativeProps = Readonly<{ includeFontPadding?: ?boolean, fontWeight?: ?string, fontFamily?: ?string, + fontVariationSettings?: ?string, /** * I cannot find where these are defined but JS complains without them. @@ -714,6 +715,7 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = { includeFontPadding: true, fontWeight: true, fontFamily: true, + fontVariationSettings: true, allowFontScaling: true, onSelectionChange: true, mostRecentEventCount: true, diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 02bfc4b552f0..f05157f9262e 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -6361,6 +6361,7 @@ public class com/facebook/react/views/textinput/ReactTextInputManager : com/face public final fun setFontSize (Lcom/facebook/react/views/textinput/ReactEditText;F)V public final fun setFontStyle (Lcom/facebook/react/views/textinput/ReactEditText;Ljava/lang/String;)V public final fun setFontVariant (Lcom/facebook/react/views/textinput/ReactEditText;Lcom/facebook/react/bridge/ReadableArray;)V + public final fun setFontVariationSettings (Lcom/facebook/react/views/textinput/ReactEditText;Ljava/lang/String;)V public final fun setFontWeight (Lcom/facebook/react/views/textinput/ReactEditText;Ljava/lang/String;)V public final fun setImportantForAutofill (Lcom/facebook/react/views/textinput/ReactEditText;Ljava/lang/String;)V public final fun setIncludeFontPadding (Lcom/facebook/react/views/textinput/ReactEditText;Z)V diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt index fd9e3f123d8f..9bb938f8b034 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt @@ -14,6 +14,7 @@ import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Rect +import android.graphics.Typeface import android.os.Build import android.os.Bundle import android.text.Editable @@ -47,6 +48,7 @@ import androidx.core.view.ViewCompat import com.facebook.common.logging.FLog import com.facebook.react.bridge.ReactSoftExceptionLogger.logSoftException import com.facebook.react.common.ReactConstants +import com.facebook.react.common.assets.ReactFontManager import com.facebook.react.common.build.ReactBuildConfig import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlags @@ -73,6 +75,7 @@ import com.facebook.react.views.text.ReactTextUpdate import com.facebook.react.views.text.ReactTypefaceUtils.applyStyles import com.facebook.react.views.text.ReactTypefaceUtils.getFontWeightAdjustment import com.facebook.react.views.text.ReactTypefaceUtils.parseFontStyle +import com.facebook.react.views.text.ReactTypefaceUtils.parseFontVariationSettings import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight import com.facebook.react.views.text.TextAttributes import com.facebook.react.views.text.TextLayoutManager @@ -135,6 +138,9 @@ public open class ReactEditText public constructor(context: Context) : AppCompat private var fontFamily: String? = null private var fontWeight = ReactConstants.UNSET private var fontStyle = ReactConstants.UNSET + internal var parsedFontVariationSettings: String? = null + private set + private var autoFocus = false private var contextMenuHidden = false private var didAttachToWindow = false @@ -646,6 +652,14 @@ public open class ReactEditText public constructor(context: Context) : AppCompat } } + internal fun setReactFontVariationSettings(fontVariationSettings: String?) { + val newParsedFontVariationSettings = parseFontVariationSettings(fontVariationSettings) + if (newParsedFontVariationSettings != parsedFontVariationSettings) { + parsedFontVariationSettings = newParsedFontVariationSettings + typefaceDirty = true + } + } + override fun setFontFeatureSettings(fontFeatureSettings: String?) { if (fontFeatureSettings != getFontFeatureSettings()) { super.setFontFeatureSettings(fontFeatureSettings) @@ -662,6 +676,9 @@ public open class ReactEditText public constructor(context: Context) : AppCompat val newTypeface = applyStyles(typeface, fontStyle, fontWeight, fontFamily, context.assets) typeface = newTypeface + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + super.setFontVariationSettings(parsedFontVariationSettings) + } // Match behavior of CustomStyleSpan and enable SUBPIXEL_TEXT_FLAG when setting anything // nonstandard @@ -669,7 +686,8 @@ public open class ReactEditText public constructor(context: Context) : AppCompat fontStyle != ReactConstants.UNSET || fontWeight != ReactConstants.UNSET || fontFamily != null || - fontFeatureSettings != null + fontFeatureSettings != null || + parsedFontVariationSettings != null paintFlags = if (enableSubpixelText) { paintFlags or Paint.SUBPIXEL_TEXT_FLAG @@ -822,12 +840,16 @@ public open class ReactEditText public constructor(context: Context) : AppCompat span.spacing == textAttributes.effectiveLetterSpacing } + val effectiveFontStyle = if (fontStyle == ReactConstants.UNSET) Typeface.NORMAL else fontStyle + val effectiveFontWeight = + if (fontWeight == ReactConstants.UNSET) ReactFontManager.TypefaceStyle.NORMAL + else fontWeight stripSpansOfKind(sb, CustomStyleSpan::class.java) { span: CustomStyleSpan -> - span.style == fontStyle && + span.style == effectiveFontStyle && span.fontFamily == fontFamily && - span.weight == fontWeight && + span.weight == effectiveFontWeight && span.fontFeatureSettings == fontFeatureSettings && - span.fontVariationSettings == null + span.fontVariationSettings == parsedFontVariationSettings } } @@ -902,14 +924,15 @@ public open class ReactEditText public constructor(context: Context) : AppCompat fontStyle != ReactConstants.UNSET || fontWeight != ReactConstants.UNSET || fontFamily != null || - fontFeatureSettings != null + fontFeatureSettings != null || + parsedFontVariationSettings != null ) { workingText.setSpan( CustomStyleSpan( fontStyle, fontWeight, fontFeatureSettings, - null, + parsedFontVariationSettings, fontFamily, context.assets, getFontWeightAdjustment(context), diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt index 0d58ffdb5ab7..2336b9ca35eb 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt @@ -256,6 +256,11 @@ public open class ReactTextInputManager public constructor() : view.fontFeatureSettings = parseFontVariant(fontVariant) } + @ReactProp(name = ViewProps.FONT_VARIATION_SETTINGS) + public fun setFontVariationSettings(view: ReactEditText, fontVariationSettings: String?) { + view.setReactFontVariationSettings(fontVariationSettings) + } + @ReactProp(name = ViewProps.INCLUDE_FONT_PADDING, defaultBoolean = true) public fun setIncludeFontPadding(view: ReactEditText, includepad: Boolean) { view.includeFontPadding = includepad diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactTextInputPropertyTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactTextInputPropertyTest.kt index f7078a7213e6..a776978e8d63 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactTextInputPropertyTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactTextInputPropertyTest.kt @@ -23,6 +23,7 @@ import android.util.DisplayMetrics import android.view.Gravity import android.view.View import android.view.inputmethod.EditorInfo +import androidx.annotation.RequiresApi import androidx.autofill.HintConstants import androidx.core.content.res.ResourcesCompat.ID_NULL import com.facebook.react.bridge.BridgeReactContext @@ -35,6 +36,7 @@ import com.facebook.react.uimanager.ReactStylesDiffMap import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.views.text.DefaultStyleValuesUtil.getDefaultTextColorHint import com.facebook.react.views.text.ReactTextUpdate +import com.facebook.react.views.text.internal.span.CustomStyleSpan import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test @@ -497,6 +499,98 @@ class ReactTextInputPropertyTest { assertThat(view.filters).isEqualTo(filters) } + @Test + fun testFontVariationSettings() { + manager.updateProperties(view, buildStyles("fontVariationSettings", "'wght' 550")) + assertThat(view.parsedFontVariationSettings).isEqualTo("'wght' 550") + + manager.updateProperties(view, buildStyles("fontVariationSettings", "invalid")) + assertThat(view.parsedFontVariationSettings).isNull() + + manager.updateProperties(view, buildStyles("fontVariationSettings", "normal")) + assertThat(view.parsedFontVariationSettings).isEmpty() + + manager.updateProperties(view, buildStyles("fontVariationSettings", "")) + assertThat(view.parsedFontVariationSettings).isEmpty() + + manager.updateProperties(view, buildStyles("fontVariationSettings", null)) + assertThat(view.parsedFontVariationSettings).isNull() + } + + @Test + fun testFontVariationSettingsOverrideFontWeightRegardlessOfPropOrder() { + manager.updateProperties( + view, + buildStyles( + "fontVariationSettings", + "'wght' 450", + "fontWeight", + "700", + ), + ) + assertThat(view.parsedFontVariationSettings).isEqualTo("'wght' 450") + + manager.updateProperties( + view, + buildStyles( + "fontWeight", + "300", + "fontVariationSettings", + "'wght' 550", + ), + ) + assertThat(view.parsedFontVariationSettings).isEqualTo("'wght' 550") + } + + @RequiresApi(Build.VERSION_CODES.M) + @Test + fun testFontVariationSettingsStripOnlyEquivalentSpans() { + manager.updateProperties( + view, + buildStyles( + "fontFamily", + "sans-serif", + "fontVariationSettings", + "'wght' 550", + ), + ) + val matchingSpan = CustomStyleSpan( + 0, + 400, + view.fontFeatureSettings, + "'wght' 550", + "sans-serif", + themedContext.assets, + ) + val differingSpan = CustomStyleSpan( + 0, + 400, + view.fontFeatureSettings, + "'wght' 700", + "sans-serif", + themedContext.assets, + ) + val textUpdate = + SpannableString("matching different").apply { + setSpan(matchingSpan, 0, 8, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + setSpan(differingSpan, 9, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + + view.maybeSetTextFromState( + ReactTextUpdate( + textUpdate, + 0, + view.gravity and Gravity.HORIZONTAL_GRAVITY_MASK, + Layout.BREAK_STRATEGY_HIGH_QUALITY, + 0, + ) + ) + + val remainingSpans = + checkNotNull(view.text).getSpans(0, view.length(), CustomStyleSpan::class.java) + assertThat(remainingSpans).containsExactly(differingSpan) + } + @Test fun testSecureTextDoesNotReplaceSameTextFromJS() { val markerSpan = MarkerSpan() diff --git a/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/android/react/renderer/components/androidtextinput/AndroidTextInputProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/android/react/renderer/components/androidtextinput/AndroidTextInputProps.cpp index 00cefce0d1fd..4c145e7beeee 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/android/react/renderer/components/androidtextinput/AndroidTextInputProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/android/react/renderer/components/androidtextinput/AndroidTextInputProps.cpp @@ -323,6 +323,10 @@ folly::dynamic AndroidTextInputProps::getDynamic() const { props["includeFontPadding"] = includeFontPadding; props["fontWeight"] = fontWeight; props["fontFamily"] = fontFamily; + props["fontVariationSettings"] = + textAttributes.fontVariationSettings.has_value() + ? folly::dynamic(*textAttributes.fontVariationSettings) + : folly::dynamic(nullptr); props["cursorColor"] = toAndroidRepr(cursorColor); props["mostRecentEventCount"] = mostRecentEventCount; props["text"] = text; @@ -622,6 +626,14 @@ folly::dynamic AndroidTextInputProps::getDiffProps( result["fontFamily"] = fontFamily; } + if (textAttributes.fontVariationSettings != + oldProps->textAttributes.fontVariationSettings) { + result["fontVariationSettings"] = + textAttributes.fontVariationSettings.has_value() + ? folly::dynamic(*textAttributes.fontVariationSettings) + : folly::dynamic(nullptr); + } + return result; } From 6d44c25efffd548451c0b5d3b947d36e0b46ba0a Mon Sep 17 00:00:00 2001 From: Evan Katz Date: Tue, 4 Aug 2026 07:45:52 -0700 Subject: [PATCH 3/3] Add variable font settings support Summary: Apply the existing `fontVariationSettings` text style prop when Fabric constructs fonts on iOS. Parse CSS-compatible axis settings into CoreText variation dictionaries while preserving absent, explicit-clear, and invalid value semantics for nested text. The parser supports quoted four-character OpenType tags and finite numeric values, rejects malformed settings as a complete unit, and applies normalized variations after the base font and feature settings are resolved. This shared attributed-text path covers Fabric `Text` and `TextInput`. Changelog: [iOS][Added] - Add `fontVariationSettings` support for Fabric text Differential Revision: D114121940 --- .../Tests/Text/RCTAttributedTextUtilsTest.mm | 86 +++++++++++++++++++ .../RCTAttributedTextUtils.mm | 4 + .../textlayoutmanager/RCTFontProperties.h | 1 + .../renderer/textlayoutmanager/RCTFontUtils.h | 11 +++ .../textlayoutmanager/RCTFontUtils.mm | 65 +++++++++++++- .../api-snapshots/ReactAppleDebugCxx.api | 1 + .../api-snapshots/ReactAppleNewarchCxx.api | 1 + .../api-snapshots/ReactAppleReleaseCxx.api | 1 + 8 files changed, 169 insertions(+), 1 deletion(-) diff --git a/packages/react-native/React/Tests/Text/RCTAttributedTextUtilsTest.mm b/packages/react-native/React/Tests/Text/RCTAttributedTextUtilsTest.mm index 902f61e11ed1..e14c46fd5281 100644 --- a/packages/react-native/React/Tests/Text/RCTAttributedTextUtilsTest.mm +++ b/packages/react-native/React/Tests/Text/RCTAttributedTextUtilsTest.mm @@ -5,14 +5,18 @@ * LICENSE file in the root directory of this source tree. */ +#import #import #import #import +#import #include #include +#include + using namespace facebook::react; @interface RCTAttributedTextUtilsTest : XCTestCase @@ -37,6 +41,88 @@ static NSTextAlignment NSTextAlignmentFromTextAlign(NSString *textAlign, LayoutD return paragraphStyle.alignment; } +static UIFont *FontFromSettings(std::optional settings, std::optional weight = std::nullopt) +{ + TextAttributes textAttributes; + textAttributes.fontVariationSettings = std::move(settings); + textAttributes.fontWeight = weight; + + NSDictionary *attributes = RCTNSTextAttributesFromTextAttributes(textAttributes); + return attributes[NSFontAttributeName]; +} + +static NSDictionary *FontVariationsFromSettings(std::optional settings) +{ + UIFont *font = FontFromSettings(std::move(settings)); + return [font.fontDescriptor objectForKey:(UIFontDescriptorAttributeName)kCTFontVariationAttribute]; +} + +- (void)testFontVariationSettings +{ + NSDictionary *variations = FontVariationsFromSettings("'wght' 550, \"opsz\" 18.5"); + + XCTAssertEqualObjects(variations[@(0x77676874)], @550); + XCTAssertEqualObjects(variations[@(0x6F70737A)], @18.5); +} + +- (void)testFontVariationSettingsParser +{ + NSDictionary *variations = + RCTParseFontVariationSettings(@"'a,bc' 1, \"wght\" -2.5e1, 'wght' 550"); + + XCTAssertEqualObjects(variations[@(0x612C6263)], @1); + XCTAssertEqualObjects(variations[@(0x77676874)], @550); +} + +- (void)testFontVariationSettingsOverrideFontWeight +{ + UIFont *font = FontFromSettings("'wght' 450", FontWeight::Weight700); + NSDictionary *variations = + [font.fontDescriptor objectForKey:(UIFontDescriptorAttributeName)kCTFontVariationAttribute]; + + XCTAssertEqualObjects(variations[@(0x77676874)], @450); +} + +- (void)testInvalidFontVariationSettingsParserValues +{ + NSArray *invalidSettings = @[ + @"'weight' 550", + @"'wght' 1e999", + @"'wght' NaN", + @"'wght' 550,", + @", 'wght' 550", + @"'wght' 550,, 'opsz' 18", + ]; + + for (NSString *settings in invalidSettings) { + XCTAssertEqual(RCTParseFontVariationSettings(settings).count, 0, @"%@ should be invalid", settings); + } +} + +- (void)testInvalidFontVariationSettingsClearVariations +{ + NSDictionary *variations = FontVariationsFromSettings("'weight' 550"); + NSDictionary *defaultVariations = FontVariationsFromSettings(std::nullopt); + + XCTAssertEqualObjects(variations, defaultVariations); +} + +- (void)testEmptyFontVariationSettingsClearVariations +{ + NSDictionary *variations = FontVariationsFromSettings(""); + NSDictionary *defaultVariations = FontVariationsFromSettings(std::nullopt); + + XCTAssertEqualObjects(variations, defaultVariations); +} + +- (void)testNormalFontVariationSettingsClearVariations +{ + NSDictionary *variations = FontVariationsFromSettings("normal"); + NSDictionary *defaultVariations = FontVariationsFromSettings(std::nullopt); + + XCTAssertEqualObjects(variations, defaultVariations); +} + - (void)testTextAlignmentStartAndEndResolveWithLayoutDirection { XCTAssertEqual(NSTextAlignmentFromTextAlign(@"start", LayoutDirection::LeftToRight), NSTextAlignmentLeft); diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm index 3f55678f65c8..346ff459dd27 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm @@ -154,6 +154,10 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex fontProperties.weight = textAttributes.fontWeight.has_value() ? RCTUIFontWeightFromInteger((NSInteger)textAttributes.fontWeight.value()) : NAN; + if (textAttributes.fontVariationSettings.has_value()) { + NSString *variationSettings = [NSString stringWithUTF8String:textAttributes.fontVariationSettings->c_str()]; + fontProperties.variations = RCTParseFontVariationSettings(variationSettings); + } fontProperties.sizeMultiplier = RCTEffectiveFontSizeMultiplierFromTextAttributes(textAttributes); return RCTFontWithFontProperties(fontProperties); diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontProperties.h b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontProperties.h index efbfdf0d894e..ad714932c21f 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontProperties.h +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontProperties.h @@ -52,6 +52,7 @@ struct RCTFontProperties { UIFontWeight weight = NAN; RCTFontStyle style = RCTFontStyleUndefined; RCTFontVariant variant = RCTFontVariantUndefined; + NSDictionary *_Nullable variations = nil; CGFloat sizeMultiplier = NAN; }; diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.h b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.h index 1558aa90d479..134009c65b0f 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.h +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.h @@ -5,6 +5,8 @@ * LICENSE file in the root directory of this source tree. */ +#pragma once + #import #import #import @@ -19,6 +21,15 @@ using RCTDefaultFontResolver = UIFont *__nullable (^)(const RCTFontProperties &) */ RCT_EXTERN void RCTSetDefaultFontResolver(RCTDefaultFontResolver handler); +/** + * Parses comma-separated, quoted four-byte ASCII axis tags and finite numeric + * values into CoreText axis values. `normal`, an empty string, and invalid + * settings normalize to an empty dictionary. Because text inheritance is + * resolved before platform parsing, invalid child settings do not fall back to + * inherited settings. + */ +RCT_EXTERN NSDictionary *RCTParseFontVariationSettings(NSString *variationSettings); + /** * Returns UIFont instance corresponded to given font properties. */ diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm index b2b27626edc4..81a0b318e5fe 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm @@ -10,6 +10,7 @@ #import #import #import +#import #import #import @@ -248,6 +249,58 @@ static RCTFontStyle RCTGetFontStyle(UIFont *font) return fontFeatures; } +NSDictionary *RCTParseFontVariationSettings(NSString *variationSettings) +{ + NSString *trimmedSettings = + [variationSettings stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (trimmedSettings.length == 0 || [trimmedSettings isEqualToString:@"normal"]) { + return @{}; + } + + static NSRegularExpression *variationExpression; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + variationExpression = + [NSRegularExpression regularExpressionWithPattern: + @R"(\s*(['"])([ -~]{4})\1\s+([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)\s*(,|$))" + options:0 + error:nil]; + }); + + NSMutableDictionary *variations = [NSMutableDictionary dictionary]; + NSUInteger location = 0; + while (location < variationSettings.length) { + NSTextCheckingResult *match = + [variationExpression firstMatchInString:variationSettings + options:NSMatchingAnchored + range:NSMakeRange(location, variationSettings.length - location)]; + if (match == nil || match.range.location != location) { + RCTLogWarn(@"Invalid fontVariationSettings value: %@", variationSettings); + return @{}; + } + + NSString *axis = [variationSettings substringWithRange:[match rangeAtIndex:2]]; + uint32_t axisIdentifier = (uint32_t)[axis characterAtIndex:0] << 24 | (uint32_t)[axis characterAtIndex:1] << 16 | + (uint32_t)[axis characterAtIndex:2] << 8 | (uint32_t)[axis characterAtIndex:3]; + NSString *valueString = [variationSettings substringWithRange:[match rangeAtIndex:3]]; + double value = valueString.doubleValue; + if (!std::isfinite(value)) { + RCTLogWarn(@"Invalid fontVariationSettings value: %@", variationSettings); + return @{}; + } + variations[@(axisIdentifier)] = @(value); + + NSString *separator = [variationSettings substringWithRange:[match rangeAtIndex:4]]; + location = NSMaxRange(match.range); + if ([separator isEqualToString:@","] && location == variationSettings.length) { + RCTLogWarn(@"Invalid fontVariationSettings value: %@", variationSettings); + return @{}; + } + } + + return variations; +} + static RCTDefaultFontResolver defaultFontResolver; void RCTSetDefaultFontResolver(RCTDefaultFontResolver handler) @@ -278,7 +331,9 @@ void RCTSetDefaultFontResolver(RCTDefaultFontResolver handler) if (font == nil) { if (defaultFontResolver != nil) { - font = defaultFontResolver(fontProperties); + RCTFontProperties baseFontProperties = fontProperties; + baseFontProperties.variations = nil; + font = defaultFontResolver(baseFontProperties); } if (font == nil) { @@ -402,5 +457,13 @@ static UIFontDescriptorSystemDesign RCTGetFontDescriptorSystemDesign(NSString *f font = [UIFont fontWithDescriptor:fontDescriptor size:effectiveFontSize]; } + NSDictionary *variations = fontProperties.variations; + if (variations != nil) { + UIFontDescriptor *fontDescriptor = [font.fontDescriptor fontDescriptorByAddingAttributes:@{ + (UIFontDescriptorAttributeName)kCTFontVariationAttribute : variations, + }]; + font = [UIFont fontWithDescriptor:fontDescriptor size:effectiveFontSize]; + } + return font; } diff --git a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api index 08edbf9b8886..3d0224042f9b 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -3191,6 +3191,7 @@ struct RCTCornerRadii { struct RCTFontProperties { public CGFloat size; public CGFloat sizeMultiplier; + public NSDictionary* _Nullable variations; public NSString* family; public RCTFontStyle style; public RCTFontVariant variant; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api index 381fe9367c80..9fb50d76eed0 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api @@ -3179,6 +3179,7 @@ struct RCTCornerRadii { struct RCTFontProperties { public CGFloat size; public CGFloat sizeMultiplier; + public NSDictionary* _Nullable variations; public NSString* family; public RCTFontStyle style; public RCTFontVariant variant; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api index fe6bc0e042b4..7b270b20810f 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api @@ -3191,6 +3191,7 @@ struct RCTCornerRadii { struct RCTFontProperties { public CGFloat size; public CGFloat sizeMultiplier; + public NSDictionary* _Nullable variations; public NSString* family; public RCTFontStyle style; public RCTFontVariant variant;