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
This commit is contained in:
Evan Katz
2026-08-09 17:09:10 -07:00
committed by meta-codesync[bot]
parent a688608090
commit 8415753e21
8 changed files with 169 additions and 1 deletions
@@ -5,14 +5,18 @@
* LICENSE file in the root directory of this source tree.
*/
#import <CoreText/CoreText.h>
#import <Foundation/Foundation.h>
#import <XCTest/XCTest.h>
#import <react/renderer/textlayoutmanager/RCTAttributedTextUtils.h>
#import <react/renderer/textlayoutmanager/RCTFontUtils.h>
#include <react/renderer/attributedstring/conversions.h>
#include <react/renderer/core/RawValue.h>
#include <utility>
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<std::string> settings, std::optional<FontWeight> weight = std::nullopt)
{
TextAttributes textAttributes;
textAttributes.fontVariationSettings = std::move(settings);
textAttributes.fontWeight = weight;
NSDictionary<NSAttributedStringKey, id> *attributes = RCTNSTextAttributesFromTextAttributes(textAttributes);
return attributes[NSFontAttributeName];
}
static NSDictionary<NSNumber *, NSNumber *> *FontVariationsFromSettings(std::optional<std::string> settings)
{
UIFont *font = FontFromSettings(std::move(settings));
return [font.fontDescriptor objectForKey:(UIFontDescriptorAttributeName)kCTFontVariationAttribute];
}
- (void)testFontVariationSettings
{
NSDictionary<NSNumber *, NSNumber *> *variations = FontVariationsFromSettings("'wght' 550, \"opsz\" 18.5");
XCTAssertEqualObjects(variations[@(0x77676874)], @550);
XCTAssertEqualObjects(variations[@(0x6F70737A)], @18.5);
}
- (void)testFontVariationSettingsParser
{
NSDictionary<NSNumber *, NSNumber *> *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<NSNumber *, NSNumber *> *variations =
[font.fontDescriptor objectForKey:(UIFontDescriptorAttributeName)kCTFontVariationAttribute];
XCTAssertEqualObjects(variations[@(0x77676874)], @450);
}
- (void)testInvalidFontVariationSettingsParserValues
{
NSArray<NSString *> *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<NSNumber *, NSNumber *> *variations = FontVariationsFromSettings("'weight' 550");
NSDictionary<NSNumber *, NSNumber *> *defaultVariations = FontVariationsFromSettings(std::nullopt);
XCTAssertEqualObjects(variations, defaultVariations);
}
- (void)testEmptyFontVariationSettingsClearVariations
{
NSDictionary<NSNumber *, NSNumber *> *variations = FontVariationsFromSettings("");
NSDictionary<NSNumber *, NSNumber *> *defaultVariations = FontVariationsFromSettings(std::nullopt);
XCTAssertEqualObjects(variations, defaultVariations);
}
- (void)testNormalFontVariationSettingsClearVariations
{
NSDictionary<NSNumber *, NSNumber *> *variations = FontVariationsFromSettings("normal");
NSDictionary<NSNumber *, NSNumber *> *defaultVariations = FontVariationsFromSettings(std::nullopt);
XCTAssertEqualObjects(variations, defaultVariations);
}
- (void)testTextAlignmentStartAndEndResolveWithLayoutDirection
{
XCTAssertEqual(NSTextAlignmentFromTextAlign(@"start", LayoutDirection::LeftToRight), NSTextAlignmentLeft);
@@ -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);
@@ -52,6 +52,7 @@ struct RCTFontProperties {
UIFontWeight weight = NAN;
RCTFontStyle style = RCTFontStyleUndefined;
RCTFontVariant variant = RCTFontVariantUndefined;
NSDictionary<NSNumber *, NSNumber *> *_Nullable variations = nil;
CGFloat sizeMultiplier = NAN;
};
@@ -5,6 +5,8 @@
* LICENSE file in the root directory of this source tree.
*/
#pragma once
#import <React/RCTDefines.h>
#import <UIKit/UIKit.h>
#import <react/renderer/textlayoutmanager/RCTFontProperties.h>
@@ -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<NSNumber *, NSNumber *> *RCTParseFontVariationSettings(NSString *variationSettings);
/**
* Returns UIFont instance corresponded to given font properties.
*/
@@ -10,6 +10,7 @@
#import <CoreText/CoreText.h>
#import <React/RCTFont+Private.h>
#import <React/RCTFont.h>
#import <React/RCTLog.h>
#import <algorithm>
#import <cmath>
@@ -248,6 +249,58 @@ static NSArray *RCTFontFeatures(RCTFontVariant fontVariant)
return fontFeatures;
}
NSDictionary<NSNumber *, NSNumber *> *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<NSNumber *, NSNumber *> *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<NSNumber *, NSNumber *> *variations = fontProperties.variations;
if (variations != nil) {
UIFontDescriptor *fontDescriptor = [font.fontDescriptor fontDescriptorByAddingAttributes:@{
(UIFontDescriptorAttributeName)kCTFontVariationAttribute : variations,
}];
font = [UIFont fontWithDescriptor:fontDescriptor size:effectiveFontSize];
}
return font;
}
@@ -3186,6 +3186,7 @@ struct RCTCornerRadii {
struct RCTFontProperties {
public CGFloat size;
public CGFloat sizeMultiplier;
public NSDictionary<NSNumber*, NSNumber*>* _Nullable variations;
public NSString* family;
public RCTFontStyle style;
public RCTFontVariant variant;
@@ -3174,6 +3174,7 @@ struct RCTCornerRadii {
struct RCTFontProperties {
public CGFloat size;
public CGFloat sizeMultiplier;
public NSDictionary<NSNumber*, NSNumber*>* _Nullable variations;
public NSString* family;
public RCTFontStyle style;
public RCTFontVariant variant;
@@ -3186,6 +3186,7 @@ struct RCTCornerRadii {
struct RCTFontProperties {
public CGFloat size;
public CGFloat sizeMultiplier;
public NSDictionary<NSNumber*, NSNumber*>* _Nullable variations;
public NSString* family;
public RCTFontStyle style;
public RCTFontVariant variant;