From 8415753e21c9e88f2a3a46e3da7cfd20b6a4d4a9 Mon Sep 17 00:00:00 2001 From: Evan Katz Date: Sun, 9 Aug 2026 17:09:10 -0700 Subject: [PATCH] Add variable font settings support (#57815) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57815 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 Reviewed By: Abbondanzo, christophpurrer Differential Revision: D114121940 fbshipit-source-id: d2b2fffd4fe723c5205e5279a466a125aa7edd38 --- .../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 902f61e11ed..e14c46fd528 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 3f55678f65c..346ff459dd2 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 UIFont *RCTEffectiveFontFromTextAttributes(const TextAttributes &t 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 efbfdf0d894..ad714932c21 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 1558aa90d47..134009c65b0 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 b2b27626edc..81a0b318e5f 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 NSArray *RCTFontFeatures(RCTFontVariant fontVariant) 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 @@ static UIFont *RCTDefaultFontWithFontProperties(const RCTFontProperties &fontPro 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 @@ UIFont *RCTFontWithFontProperties(RCTFontProperties fontProperties) 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 d90e9ee34bc..143a3c05573 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -3186,6 +3186,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 fb7860acf01..3e868ec9f2f 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api @@ -3174,6 +3174,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 36445db589c..67d36ab67fb 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api @@ -3186,6 +3186,7 @@ struct RCTCornerRadii { struct RCTFontProperties { public CGFloat size; public CGFloat sizeMultiplier; + public NSDictionary* _Nullable variations; public NSString* family; public RCTFontStyle style; public RCTFontVariant variant;