From 05890a5942e7e268934860bfbea648b858adfb72 Mon Sep 17 00:00:00 2001 From: Valentin Shergin Date: Mon, 7 May 2018 18:58:06 -0700 Subject: [PATCH] Fabric/Text: textlayoutmanager Summary: TextLayoutManager measures and renders text using iOS specific APIs (CoreText & TextKit). By desing, only this module should contain platfrom-specific text functionality. Reviewed By: mdvacca Differential Revision: D7751852 fbshipit-source-id: fd6e1907df617fe5a4479ea08f207946765b3a45 --- React.podspec | 9 + ReactCommon/fabric/textlayoutmanager/BUCK | 88 +++++++ .../NSTextStorage+FontScaling.h | 20 ++ .../NSTextStorage+FontScaling.m | 137 +++++++++++ .../RCTAttributedTextUtils.h | 23 ++ .../RCTAttributedTextUtils.mm | 227 ++++++++++++++++++ .../textlayoutmanager/RCTFontProperties.h | 38 +++ .../fabric/textlayoutmanager/RCTFontUtils.h | 19 ++ .../fabric/textlayoutmanager/RCTFontUtils.mm | 153 ++++++++++++ .../textlayoutmanager/RCTTextLayoutManager.h | 32 +++ .../textlayoutmanager/RCTTextLayoutManager.mm | 97 ++++++++ .../RCTTextPrimitivesConversions.h | 75 ++++++ .../textlayoutmanager/TextLayoutManager.h | 51 ++++ .../textlayoutmanager/TextLayoutManager.mm | 40 +++ .../tests/TextLayoutManagerTest.cpp | 18 ++ 15 files changed, 1027 insertions(+) create mode 100644 ReactCommon/fabric/textlayoutmanager/BUCK create mode 100644 ReactCommon/fabric/textlayoutmanager/NSTextStorage+FontScaling.h create mode 100644 ReactCommon/fabric/textlayoutmanager/NSTextStorage+FontScaling.m create mode 100644 ReactCommon/fabric/textlayoutmanager/RCTAttributedTextUtils.h create mode 100644 ReactCommon/fabric/textlayoutmanager/RCTAttributedTextUtils.mm create mode 100644 ReactCommon/fabric/textlayoutmanager/RCTFontProperties.h create mode 100644 ReactCommon/fabric/textlayoutmanager/RCTFontUtils.h create mode 100644 ReactCommon/fabric/textlayoutmanager/RCTFontUtils.mm create mode 100644 ReactCommon/fabric/textlayoutmanager/RCTTextLayoutManager.h create mode 100644 ReactCommon/fabric/textlayoutmanager/RCTTextLayoutManager.mm create mode 100644 ReactCommon/fabric/textlayoutmanager/RCTTextPrimitivesConversions.h create mode 100644 ReactCommon/fabric/textlayoutmanager/TextLayoutManager.h create mode 100644 ReactCommon/fabric/textlayoutmanager/TextLayoutManager.mm create mode 100644 ReactCommon/fabric/textlayoutmanager/tests/TextLayoutManagerTest.cpp diff --git a/React.podspec b/React.podspec index b985ee3ea..44d6ef7f5 100644 --- a/React.podspec +++ b/React.podspec @@ -185,6 +185,15 @@ Pod::Spec.new do |s| sss.pod_target_xcconfig = { "HEADER_SEARCH_PATHS" => "\"$(PODS_TARGET_SRCROOT)/ReactCommon\" \"$(PODS_ROOT)/Folly\"" } end + ss.subspec "textlayoutmanager" do |sss| + sss.dependency "Folly", folly_version + sss.compiler_flags = folly_compiler_flags + sss.source_files = "ReactCommon/fabric/textlayoutmanager/**/*.{cpp,h}" + sss.exclude_files = "**/tests/*" + sss.header_dir = "fabric/textlayoutmanager" + sss.pod_target_xcconfig = { "HEADER_SEARCH_PATHS" => "\"$(PODS_TARGET_SRCROOT)/ReactCommon\" \"$(PODS_ROOT)/Folly\"" } + end + ss.subspec "uimanager" do |sss| sss.dependency "Folly", folly_version sss.compiler_flags = folly_compiler_flags diff --git a/ReactCommon/fabric/textlayoutmanager/BUCK b/ReactCommon/fabric/textlayoutmanager/BUCK new file mode 100644 index 000000000..c7bb2f92e --- /dev/null +++ b/ReactCommon/fabric/textlayoutmanager/BUCK @@ -0,0 +1,88 @@ +load("//configurations/buck/apple:flag_defs.bzl", "get_debug_preprocessor_flags", "get_fbobjc_enable_exception_lang_compiler_flags") +load("//ReactNative:DEFS.bzl", "IS_OSS_BUILD", "react_native_xplat_target", "rn_xplat_cxx_library", "get_apple_inspector_flags", "APPLE") + +APPLE_COMPILER_FLAGS = [] + +if not IS_OSS_BUILD: + load("@xplat//configurations/buck/apple:flag_defs.bzl", "get_static_library_ios_flags", "flags") + APPLE_COMPILER_FLAGS = flags.get_flag_value(get_static_library_ios_flags(), 'compiler_flags') + +rn_xplat_cxx_library( + name = "textlayoutmanager", + srcs = glob( + [ + "**/*.cpp", + "**/*.mm", + ], + excludes = glob(["tests/**/*.cpp"]), + ), + headers = glob( + ["**/*.h"], + excludes = glob(["tests/**/*.h"]), + ), + header_namespace = "", + exported_headers = subdir_glob( + [ + ("", "*.h"), + ], + prefix = "fabric/textlayoutmanager", + ), + compiler_flags = [ + "-fexceptions", + "-frtti", + "-std=c++14", + "-Wall", + ], + fbobjc_compiler_flags = APPLE_COMPILER_FLAGS, + fbobjc_preprocessor_flags = get_debug_preprocessor_flags() + get_apple_inspector_flags(), + fbobjc_tests = [ + ":tests", + ], + force_static = True, + macosx_tests_override = [], + frameworks = [ + "$SDKROOT/System/Library/Frameworks/Foundation.framework", + "$SDKROOT/System/Library/Frameworks/QuartzCore.framework", + "$SDKROOT/System/Library/Frameworks/UIKit.framework", + ], + lang_compiler_flags = get_fbobjc_enable_exception_lang_compiler_flags(), + preprocessor_flags = get_debug_preprocessor_flags() + [ + "-DLOG_TAG=\"ReactNative\"", + "-DWITH_FBSYSTRACE=1", + ], + tests = [], + visibility = ["PUBLIC"], + deps = [ + "xplat//fbsystrace:fbsystrace", + "xplat//folly:headers_only", + "xplat//folly:memory", + "xplat//folly:molly", + "xplat//third-party/glog:glog", + react_native_xplat_target("fabric/attributedstring:attributedstring"), + react_native_xplat_target("fabric/core:core"), + react_native_xplat_target("fabric/debug:debug"), + react_native_xplat_target("fabric/graphics:graphics"), + ], +) + +if not IS_OSS_BUILD: + load("@xplat//build_defs:fb_xplat_cxx_test.bzl", "fb_xplat_cxx_test") + + fb_xplat_cxx_test( + name = "tests", + srcs = glob(["tests/**/*.cpp"]), + headers = glob(["tests/**/*.h"]), + contacts = ["oncall+react_native@xmail.facebook.com"], + compiler_flags = [ + "-fexceptions", + "-frtti", + "-std=c++14", + "-Wall", + ], + platforms = APPLE, + deps = [ + "xplat//folly:molly", + "xplat//third-party/gmock:gtest", + ":textlayoutmanager", + ], + ) diff --git a/ReactCommon/fabric/textlayoutmanager/NSTextStorage+FontScaling.h b/ReactCommon/fabric/textlayoutmanager/NSTextStorage+FontScaling.h new file mode 100644 index 000000000..8372a0d2b --- /dev/null +++ b/ReactCommon/fabric/textlayoutmanager/NSTextStorage+FontScaling.h @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +@interface NSTextStorage (FontScaling) + +- (void)scaleFontSizeToFitSize:(CGSize)size + minimumFontSize:(CGFloat)minimumFontSize + maximumFontSize:(CGFloat)maximumFontSize; + +- (void)scaleFontSizeWithRatio:(CGFloat)ratio + minimumFontSize:(CGFloat)minimumFontSize + maximumFontSize:(CGFloat)maximumFontSize; + +@end diff --git a/ReactCommon/fabric/textlayoutmanager/NSTextStorage+FontScaling.m b/ReactCommon/fabric/textlayoutmanager/NSTextStorage+FontScaling.m new file mode 100644 index 000000000..5002dddd8 --- /dev/null +++ b/ReactCommon/fabric/textlayoutmanager/NSTextStorage+FontScaling.m @@ -0,0 +1,137 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "NSTextStorage+FontScaling.h" + +typedef NS_OPTIONS(NSInteger, RCTTextSizeComparisonOptions) { + RCTTextSizeComparisonSmaller = 1 << 0, + RCTTextSizeComparisonLarger = 1 << 1, + RCTTextSizeComparisonWithinRange = 1 << 2, +}; + +@implementation NSTextStorage (FontScaling) + +- (void)scaleFontSizeToFitSize:(CGSize)size + minimumFontSize:(CGFloat)minimumFontSize + maximumFontSize:(CGFloat)maximumFontSize +{ + CGFloat bottomRatio = 1.0/128.0; + CGFloat topRatio = 128.0; + CGFloat ratio = 1.0; + + NSAttributedString *originalAttributedString = [self copy]; + + CGFloat lastRatioWhichFits = 0.02; + + while (true) { + [self scaleFontSizeWithRatio:ratio + minimumFontSize:minimumFontSize + maximumFontSize:maximumFontSize]; + + RCTTextSizeComparisonOptions comparsion = + [self compareToSize:size thresholdRatio:0.01]; + + if ( + (comparsion & RCTTextSizeComparisonWithinRange) && + (comparsion & RCTTextSizeComparisonSmaller) + ) { + return; + } else if (comparsion & RCTTextSizeComparisonSmaller) { + bottomRatio = ratio; + lastRatioWhichFits = ratio; + } else { + topRatio = ratio; + } + + ratio = (topRatio + bottomRatio) / 2.0; + + CGFloat kRatioThreshold = 0.005; + if ( + ABS(topRatio - bottomRatio) < kRatioThreshold || + ABS(topRatio - ratio) < kRatioThreshold || + ABS(bottomRatio - ratio) < kRatioThreshold + ) { + [self replaceCharactersInRange:(NSRange){0, self.length} + withAttributedString:originalAttributedString]; + + [self scaleFontSizeWithRatio:lastRatioWhichFits + minimumFontSize:minimumFontSize + maximumFontSize:maximumFontSize]; + return; + } + + [self replaceCharactersInRange:(NSRange){0, self.length} + withAttributedString:originalAttributedString]; + } +} + + +- (RCTTextSizeComparisonOptions)compareToSize:(CGSize)size thresholdRatio:(CGFloat)thresholdRatio +{ + NSLayoutManager *layoutManager = self.layoutManagers.firstObject; + NSTextContainer *textContainer = layoutManager.textContainers.firstObject; + + [layoutManager ensureLayoutForTextContainer:textContainer]; + + // Does it fit the text container? + NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer]; + NSRange truncatedGlyphRange = [layoutManager truncatedGlyphRangeInLineFragmentForGlyphAtIndex:glyphRange.length - 1]; + + if (truncatedGlyphRange.location != NSNotFound) { + return RCTTextSizeComparisonLarger; + } + + CGSize measuredSize = [layoutManager usedRectForTextContainer:textContainer].size; + + // Does it fit the size? + BOOL fitsSize = + size.width >= measuredSize.width && + size.height >= measuredSize.height; + + CGSize thresholdSize = (CGSize){ + size.width * thresholdRatio, + size.height * thresholdRatio, + }; + + RCTTextSizeComparisonOptions result = 0; + + result |= (fitsSize) ? RCTTextSizeComparisonSmaller : RCTTextSizeComparisonLarger; + + if (ABS(measuredSize.width - size.width) < thresholdSize.width) { + result = result | RCTTextSizeComparisonWithinRange; + } + + return result; +} + +- (void)scaleFontSizeWithRatio:(CGFloat)ratio + minimumFontSize:(CGFloat)minimumFontSize + maximumFontSize:(CGFloat)maximumFontSize +{ + [self beginEditing]; + + [self enumerateAttribute:NSFontAttributeName + inRange:(NSRange){0, self.length} + options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired + usingBlock: + ^(UIFont *_Nullable font, NSRange range, BOOL *_Nonnull stop) { + if (!font) { + return; + } + + CGFloat fontSize = MAX(MIN(font.pointSize * ratio, maximumFontSize), minimumFontSize); + + [self addAttribute:NSFontAttributeName + value:[font fontWithSize:fontSize] + range:range]; + } + ]; + + [self endEditing]; +} + +@end diff --git a/ReactCommon/fabric/textlayoutmanager/RCTAttributedTextUtils.h b/ReactCommon/fabric/textlayoutmanager/RCTAttributedTextUtils.h new file mode 100644 index 000000000..32b0a387d --- /dev/null +++ b/ReactCommon/fabric/textlayoutmanager/RCTAttributedTextUtils.h @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +#include +#include + +NS_ASSUME_NONNULL_BEGIN + +NSString *const RCTAttributedStringIsHighlightedAttributeName = @"IsHighlighted"; +NSString *const RCTAttributedStringReactTagAttributeName = @"ReactTag"; + +/** + * Constructs ready-to-render `NSAttributedString` by given `AttributedString`. + */ +NSAttributedString *RCTNSAttributedStringFromAttributedString(const facebook::react::AttributedString &attributedString); + +NS_ASSUME_NONNULL_END diff --git a/ReactCommon/fabric/textlayoutmanager/RCTAttributedTextUtils.mm b/ReactCommon/fabric/textlayoutmanager/RCTAttributedTextUtils.mm new file mode 100644 index 000000000..da7c70fd0 --- /dev/null +++ b/ReactCommon/fabric/textlayoutmanager/RCTAttributedTextUtils.mm @@ -0,0 +1,227 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "RCTAttributedTextUtils.h" + +#include +#include +#include +#include + +inline static UIFont *RCTEffectiveFontFromTextAttributes(const TextAttributes &textAttributes) { + NSString *fontFamily = [NSString stringWithCString:textAttributes.fontFamily.c_str() + encoding:NSASCIIStringEncoding]; + + RCTFontProperties fontProperties; + fontProperties.family = fontFamily; + fontProperties.size = textAttributes.fontSize; + fontProperties.style = textAttributes.fontStyle.has_value() ? RCTFontStyleFromFontStyle(textAttributes.fontStyle.value()) : RCTFontStyleUndefined; + fontProperties.variant = textAttributes.fontVariant.has_value() ? RCTFontVariantFromFontVariant(textAttributes.fontVariant.value()) : RCTFontVariantDefault; + fontProperties.weight = textAttributes.fontWeight.has_value() ? CGFloat(textAttributes.fontWeight.value()) : NAN; + fontProperties.sizeMultiplier = textAttributes.fontSizeMultiplier; + + return RCTFontWithFontProperties(fontProperties); +} + +inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const TextAttributes &textAttributes) { + return textAttributes.allowFontScaling.value_or(true) && !isnan(textAttributes.fontSizeMultiplier) ? textAttributes.fontSizeMultiplier : 1.0; +} + +inline static UIColor *RCTEffectiveForegroundColorFromTextAttributes(const TextAttributes &textAttributes) { + UIColor *effectiveForegroundColor = RCTUIColorFromSharedColor(textAttributes.foregroundColor) ?: [UIColor blackColor]; + + if (!isnan(textAttributes.opacity)) { + effectiveForegroundColor = + [effectiveForegroundColor colorWithAlphaComponent:CGColorGetAlpha(effectiveForegroundColor.CGColor) * textAttributes.opacity]; + } + + return effectiveForegroundColor; +} + +inline static UIColor *RCTEffectiveBackgroundColorFromTextAttributes(const TextAttributes &textAttributes) { + UIColor *effectiveBackgroundColor = RCTUIColorFromSharedColor(textAttributes.backgroundColor); + + if (effectiveBackgroundColor && !isnan(textAttributes.opacity)) { + effectiveBackgroundColor = + [effectiveBackgroundColor colorWithAlphaComponent:CGColorGetAlpha(effectiveBackgroundColor.CGColor) * textAttributes.opacity]; + } + + return effectiveBackgroundColor ?: [UIColor clearColor]; +} + +static NSDictionary *RCTNSTextAttributesFromTextAttributes(const TextAttributes &textAttributes) { + NSMutableDictionary *attributes = + [NSMutableDictionary dictionaryWithCapacity:10]; + + // Font + UIFont *font = RCTEffectiveFontFromTextAttributes(textAttributes); + if (font) { + attributes[NSFontAttributeName] = font; + } + + // Colors + UIColor *effectiveForegroundColor = RCTEffectiveForegroundColorFromTextAttributes(textAttributes); + + if (textAttributes.foregroundColor || !isnan(textAttributes.opacity)) { + attributes[NSForegroundColorAttributeName] = effectiveForegroundColor; + } + + if (textAttributes.backgroundColor || !isnan(textAttributes.opacity)) { + attributes[NSBackgroundColorAttributeName] = RCTEffectiveBackgroundColorFromTextAttributes(textAttributes); + } + + // Kerning + if (!isnan(textAttributes.letterSpacing)) { + attributes[NSKernAttributeName] = @(textAttributes.letterSpacing); + } + + // Paragraph Style + NSMutableParagraphStyle *paragraphStyle = [NSMutableParagraphStyle new]; + BOOL isParagraphStyleUsed = NO; + if (textAttributes.alignment.has_value()) { + TextAlignment textAlignment = textAttributes.alignment.value_or(TextAlignment::Natural); + if (textAttributes.layoutDirection.value_or(LayoutDirection::LeftToRight) == LayoutDirection::RightToLeft) { + if (textAlignment == TextAlignment::Right) { + textAlignment = TextAlignment::Left; + } else if (textAlignment == TextAlignment::Left) { + textAlignment = TextAlignment::Right; + } + } + + paragraphStyle.alignment = + RCTNSTextAlignmentFromTextAlignment(textAlignment); + isParagraphStyleUsed = YES; + } + + if (textAttributes.baseWritingDirection.has_value()) { + paragraphStyle.baseWritingDirection = + RCTNSWritingDirectionFromWritingDirection(textAttributes.baseWritingDirection.value()); + isParagraphStyleUsed = YES; + } + + if (!isnan(textAttributes.lineHeight)) { + CGFloat lineHeight = + textAttributes.lineHeight * RCTEffectiveFontSizeMultiplierFromTextAttributes(textAttributes); + paragraphStyle.minimumLineHeight = lineHeight; + paragraphStyle.maximumLineHeight = lineHeight; + isParagraphStyleUsed = YES; + } + + if (isParagraphStyleUsed) { + attributes[NSParagraphStyleAttributeName] = paragraphStyle; + } + + // Decoration + if (textAttributes.textDecorationLineType.value_or(TextDecorationLineType::None) != TextDecorationLineType::None) { + auto textDecorationLineType = textAttributes.textDecorationLineType.value(); + + NSUnderlineStyle style = + RCTNSUnderlineStyleFromStyleAndPattern( + textAttributes.textDecorationLineStyle.value_or(TextDecorationLineStyle::Single), + textAttributes.textDecorationLinePattern.value_or(TextDecorationLinePattern::Solid) + ); + + UIColor *textDecorationColor = RCTUIColorFromSharedColor(textAttributes.textDecorationColor); + + // Underline + if (textDecorationLineType == TextDecorationLineType::Underline || + textDecorationLineType == TextDecorationLineType::UnderlineStrikethrough) { + + attributes[NSUnderlineStyleAttributeName] = @(style); + + if (textDecorationColor) { + attributes[NSUnderlineColorAttributeName] = textDecorationColor; + } + } + + // Strikethrough + if (textDecorationLineType == TextDecorationLineType::Strikethrough || + textDecorationLineType == TextDecorationLineType::UnderlineStrikethrough) { + + attributes[NSStrikethroughStyleAttributeName] = @(style); + + if (textDecorationColor) { + attributes[NSStrikethroughColorAttributeName] = textDecorationColor; + } + } + } + + // Shadow + if (textAttributes.textShadowOffset.has_value()) { + auto textShadowOffset = textAttributes.textShadowOffset.value(); + NSShadow *shadow = [NSShadow new]; + shadow.shadowOffset = CGSize {textShadowOffset.x, textShadowOffset.y}; + shadow.shadowBlurRadius = textAttributes.textShadowRadius; + shadow.shadowColor = RCTUIColorFromSharedColor(textAttributes.textShadowColor); + attributes[NSShadowAttributeName] = shadow; + } + + // Special + if (textAttributes.isHighlighted) { + attributes[RCTAttributedStringIsHighlightedAttributeName] = @YES; + } + + return [attributes copy]; +} + +NSAttributedString *RCTNSAttributedStringFromAttributedString(const AttributedString &attributedString) { + NSMutableAttributedString *nsAttributedString = [[NSMutableAttributedString alloc] init]; + + [nsAttributedString beginEditing]; + + for (auto fragment : attributedString.getFragments()) { + NSAttributedString *nsAttributedStringFragment; + + SharedLayoutableShadowNode layoutableShadowNode = + std::dynamic_pointer_cast(fragment.shadowNode); + + if (layoutableShadowNode) { + auto layoutMetrics = layoutableShadowNode->getLayoutMetrics(); + CGRect bounds = { + .origin = { + .x = layoutMetrics.frame.origin.x, + .y = layoutMetrics.frame.origin.y + }, + .size = { + .width = layoutMetrics.frame.size.width, + .height = layoutMetrics.frame.size.height + } + }; + + NSTextAttachment *attachment = [NSTextAttachment new]; + attachment.bounds = bounds; + + nsAttributedStringFragment = [NSAttributedString attributedStringWithAttachment:attachment]; + } else { + NSString *string = + [NSString stringWithCString:fragment.string.c_str() + encoding:NSASCIIStringEncoding]; + + nsAttributedStringFragment = + [[NSAttributedString alloc] initWithString:string + attributes:RCTNSTextAttributesFromTextAttributes(fragment.textAttributes)]; + } + + NSMutableAttributedString *nsMutableAttributedStringFragment = + [[NSMutableAttributedString alloc] initWithAttributedString:nsAttributedStringFragment]; + + if (fragment.shadowNode) { + NSDictionary *additionalTextAttributes = @{ + RCTAttributedStringReactTagAttributeName: @(fragment.shadowNode->getTag()) + }; + + [nsMutableAttributedStringFragment setAttributes:additionalTextAttributes + range:NSMakeRange(0, nsMutableAttributedStringFragment.length)]; + } + + [nsAttributedString appendAttributedString:nsMutableAttributedStringFragment]; + } + + [nsAttributedString endEditing]; + + return nsAttributedString; +} diff --git a/ReactCommon/fabric/textlayoutmanager/RCTFontProperties.h b/ReactCommon/fabric/textlayoutmanager/RCTFontProperties.h new file mode 100644 index 000000000..9f8680b47 --- /dev/null +++ b/ReactCommon/fabric/textlayoutmanager/RCTFontProperties.h @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM(NSInteger, RCTFontStyle) { + RCTFontStyleUndefined = -1, + RCTFontStyleNormal, + RCTFontStyleItalic, + RCTFontStyleOblique, +}; + +typedef NS_OPTIONS(NSInteger, RCTFontVariant) { + RCTFontVariantUndefined = -1, + RCTFontVariantDefault = 0, + RCTFontVariantSmallCaps = 1 << 1, + RCTFontVariantOldstyleNums = 1 << 2, + RCTFontVariantLiningNums = 1 << 3, + RCTFontVariantTabularNums = 1 << 4, + RCTFontVariantProportionalNums = 1 << 5, +}; + +struct RCTFontProperties { + NSString *family; + CGFloat size; + UIFontWeight weight; + RCTFontStyle style; + RCTFontVariant variant; + CGFloat sizeMultiplier; +}; + +NS_ASSUME_NONNULL_END diff --git a/ReactCommon/fabric/textlayoutmanager/RCTFontUtils.h b/ReactCommon/fabric/textlayoutmanager/RCTFontUtils.h new file mode 100644 index 000000000..084a2b24d --- /dev/null +++ b/ReactCommon/fabric/textlayoutmanager/RCTFontUtils.h @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + * Returns UIFont instance corresponded to given font properties. + */ +UIFont *RCTFontWithFontProperties(RCTFontProperties fontProperties); + +NS_ASSUME_NONNULL_END diff --git a/ReactCommon/fabric/textlayoutmanager/RCTFontUtils.mm b/ReactCommon/fabric/textlayoutmanager/RCTFontUtils.mm new file mode 100644 index 000000000..38a3d80f5 --- /dev/null +++ b/ReactCommon/fabric/textlayoutmanager/RCTFontUtils.mm @@ -0,0 +1,153 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "RCTFontUtils.h" + +#import + +static RCTFontProperties RCTDefaultFontProperties() { + static RCTFontProperties defaultFontProperties; + static dispatch_once_t onceToken; + + dispatch_once(&onceToken, ^{ + defaultFontProperties.size = 14; + defaultFontProperties.family = + [UIFont systemFontOfSize:defaultFontProperties.size].familyName; + defaultFontProperties.style = RCTFontStyleNormal; + defaultFontProperties.variant = RCTFontVariantDefault; + defaultFontProperties.sizeMultiplier = 1.0; + }); + + return defaultFontProperties; +} + +static RCTFontProperties RCTResolveFontProperties(RCTFontProperties fontProperties) { + RCTFontProperties defaultFontProperties = RCTDefaultFontProperties(); + fontProperties.family = fontProperties.family.length && ![fontProperties.family isEqualToString:@"System"] ? fontProperties.family : defaultFontProperties.family; + fontProperties.size = !isnan(fontProperties.size) ? fontProperties.size : defaultFontProperties.size; + fontProperties.weight = !isnan(fontProperties.weight) ? fontProperties.weight : defaultFontProperties.weight; + fontProperties.style = fontProperties.style != RCTFontStyleUndefined ? fontProperties.style : defaultFontProperties.style; + fontProperties.variant = fontProperties.variant != RCTFontVariantUndefined ? fontProperties.variant : defaultFontProperties.variant; + return fontProperties; +} + +static UIFontWeight RCTGetFontWeight(UIFont *font) { + NSDictionary *traits = [font.fontDescriptor objectForKey:UIFontDescriptorTraitsAttribute]; + return [traits[UIFontWeightTrait] doubleValue]; +} + +static RCTFontStyle RCTGetFontStyle(UIFont *font) { + NSDictionary *traits = [font.fontDescriptor objectForKey:UIFontDescriptorTraitsAttribute]; + UIFontDescriptorSymbolicTraits symbolicTraits = [traits[UIFontSymbolicTrait] unsignedIntValue]; + if (symbolicTraits & UIFontDescriptorTraitItalic) { + return RCTFontStyleItalic; + } + + return RCTFontStyleNormal; +} + +static NSArray *RCTFontFeatures(RCTFontVariant fontVariant) { + // FIXME: + return @[]; +} + +static UIFont *RCTDefaultFontWithFontProperties(RCTFontProperties fontProperties) { + static NSCache *fontCache; + static std::mutex fontCacheMutex; + + NSString *cacheKey = [NSString stringWithFormat:@"%.1f/%.2f", fontProperties.size, fontProperties.weight]; + UIFont *font; + + { + std::lock_guard lock(fontCacheMutex); + if (!fontCache) { + fontCache = [NSCache new]; + } + font = [fontCache objectForKey:cacheKey]; + } + + if (!font) { + font = [UIFont systemFontOfSize:fontProperties.size + weight:fontProperties.weight]; + + if (fontProperties.variant == RCTFontStyleItalic) { + UIFontDescriptor *fontDescriptor = [font fontDescriptor]; + UIFontDescriptorSymbolicTraits symbolicTraits = fontDescriptor.symbolicTraits; + + symbolicTraits |= UIFontDescriptorTraitItalic; + + fontDescriptor = [fontDescriptor fontDescriptorWithSymbolicTraits:symbolicTraits]; + font = [UIFont fontWithDescriptor:fontDescriptor size:fontProperties.size]; + } + + { + std::lock_guard lock(fontCacheMutex); + [fontCache setObject:font forKey:cacheKey]; + } + } + + return font; +} + +UIFont *RCTFontWithFontProperties(RCTFontProperties fontProperties) { + RCTFontProperties defaultFontProperties = RCTDefaultFontProperties(); + fontProperties = RCTResolveFontProperties(fontProperties); + + CGFloat effectiveFontSize = fontProperties.sizeMultiplier * fontProperties.size; + UIFont *font; + if ([fontProperties.family isEqualToString:defaultFontProperties.family]) { + // Handle system font as special case. This ensures that we preserve + // the specific metrics of the standard system font as closely as possible. + font = RCTDefaultFontWithFontProperties(fontProperties); + } else { + NSArray *fontNames = + [UIFont fontNamesForFamilyName:fontProperties.family]; + + if (fontNames.count == 0) { + // Gracefully handle being given a font name rather than font family, for + // example: "Helvetica Light Oblique" rather than just "Helvetica". + font = [UIFont fontWithName:fontProperties.family size:effectiveFontSize]; + + if (!font) { + // Failback to system font. + font = [UIFont systemFontOfSize:effectiveFontSize weight:fontProperties.weight]; + } + } else { + // Get the closest font that matches the given weight for the fontFamily + CGFloat closestWeight = INFINITY; + for (NSString *name in fontNames) { + UIFont *fontMatch = [UIFont fontWithName:name size:effectiveFontSize]; + + if (RCTGetFontStyle(fontMatch) != fontProperties.style) { + continue; + } + + CGFloat testWeight = RCTGetFontWeight(fontMatch); + if (ABS(testWeight - fontProperties.weight) < ABS(closestWeight - fontProperties.weight)) { + font = fontMatch; + closestWeight = testWeight; + } + } + + if (!font) { + // If we still don't have a match at least return the first font in the fontFamily + // This is to support built-in font Zapfino and other custom single font families like Impact + font = [UIFont fontWithName:fontNames[0] size:effectiveFontSize]; + } + } + } + + // Apply font variants to font object. + if (fontProperties.variant != RCTFontVariantDefault) { + NSArray *fontFeatures = RCTFontFeatures(fontProperties.variant); + UIFontDescriptor *fontDescriptor = + [font.fontDescriptor fontDescriptorByAddingAttributes:@{UIFontDescriptorFeatureSettingsAttribute: fontFeatures}]; + font = [UIFont fontWithDescriptor:fontDescriptor size:effectiveFontSize]; + } + + return font; +} diff --git a/ReactCommon/fabric/textlayoutmanager/RCTTextLayoutManager.h b/ReactCommon/fabric/textlayoutmanager/RCTTextLayoutManager.h new file mode 100644 index 000000000..8a745aa4a --- /dev/null +++ b/ReactCommon/fabric/textlayoutmanager/RCTTextLayoutManager.h @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +#import +#import +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + * iOS-specific TextLayoutManager + */ +@interface RCTTextLayoutManager : NSObject + +- (facebook::react::Size)measureWithAttributedString:(facebook::react::AttributedString)attributedString + paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes + layoutConstraints:(facebook::react::LayoutConstraints)layoutConstraints; + +- (void)drawAttributedString:(facebook::react::AttributedString)attributedString + paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes + frame:(CGRect)frame; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ReactCommon/fabric/textlayoutmanager/RCTTextLayoutManager.mm b/ReactCommon/fabric/textlayoutmanager/RCTTextLayoutManager.mm new file mode 100644 index 000000000..f729c9c81 --- /dev/null +++ b/ReactCommon/fabric/textlayoutmanager/RCTTextLayoutManager.mm @@ -0,0 +1,97 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "RCTTextLayoutManager.h" + +#import "NSTextStorage+FontScaling.h" +#import "RCTAttributedTextUtils.h" + +using namespace facebook::react; + +@implementation RCTTextLayoutManager + +static NSLineBreakMode RCTNSLineBreakModeFromWritingDirection(EllipsizeMode ellipsizeMode) { + switch (ellipsizeMode) { + case EllipsizeMode::Clip: return NSLineBreakByClipping; + case EllipsizeMode::Head: return NSLineBreakByTruncatingHead; + case EllipsizeMode::Tail: return NSLineBreakByTruncatingTail; + case EllipsizeMode::Middle: return NSLineBreakByTruncatingMiddle; + } +} + +- (facebook::react::Size)measureWithAttributedString:(AttributedString)attributedString + paragraphAttributes:(ParagraphAttributes)paragraphAttributes + layoutConstraints:(LayoutConstraints)layoutConstraints +{ + CGSize maximumSize = CGSize {layoutConstraints.maximumSize.width, layoutConstraints.maximumSize.height}; + NSTextStorage *textStorage = + [self _textStorageAndLayoutManagerWithAttributesString:RCTNSAttributedStringFromAttributedString(attributedString) + paragraphAttributes:paragraphAttributes + size:maximumSize]; + + NSLayoutManager *layoutManager = textStorage.layoutManagers.firstObject; + NSTextContainer *textContainer = layoutManager.textContainers.firstObject; + [layoutManager ensureLayoutForTextContainer:textContainer]; + + CGSize size = [layoutManager usedRectForTextContainer:textContainer].size; + + size = (CGSize){ + MIN(size.width, maximumSize.width), + MIN(size.height, maximumSize.height) + }; + + return facebook::react::Size {size.width, size.height}; +} + +- (void)drawAttributedString:(AttributedString)attributedString + paragraphAttributes:(ParagraphAttributes)paragraphAttributes + frame:(CGRect)frame +{ + NSTextStorage *textStorage = + [self _textStorageAndLayoutManagerWithAttributesString:RCTNSAttributedStringFromAttributedString(attributedString) + paragraphAttributes:paragraphAttributes + size:frame.size]; + NSLayoutManager *layoutManager = textStorage.layoutManagers.firstObject; + NSTextContainer *textContainer = layoutManager.textContainers.firstObject; + + NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer]; + [layoutManager drawBackgroundForGlyphRange:glyphRange atPoint:frame.origin]; + [layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:frame.origin]; +} + + +- (NSTextStorage *)_textStorageAndLayoutManagerWithAttributesString:(NSAttributedString *)attributedString + paragraphAttributes:(ParagraphAttributes)paragraphAttributes + size:(CGSize)size +{ + NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:size]; + + textContainer.lineFragmentPadding = 0.0; // Note, the default value is 5. + textContainer.lineBreakMode = + paragraphAttributes.maximumNumberOfLines > 0 ? RCTNSLineBreakModeFromWritingDirection(paragraphAttributes.ellipsizeMode) : NSLineBreakByClipping; + textContainer.maximumNumberOfLines = paragraphAttributes.maximumNumberOfLines; + + NSLayoutManager *layoutManager = [NSLayoutManager new]; + [layoutManager addTextContainer:textContainer]; + + NSTextStorage *textStorage = + [[NSTextStorage alloc] initWithAttributedString:attributedString]; + + [textStorage addLayoutManager:layoutManager]; + + if (paragraphAttributes.adjustsFontSizeToFit) { + CGFloat minimumFontSize = !isnan(paragraphAttributes.minimumFontSize) ? paragraphAttributes.minimumFontSize : 4.0; + CGFloat maximumFontSize = !isnan(paragraphAttributes.maximumFontSize) ? paragraphAttributes.maximumFontSize : 96.0; + [textStorage scaleFontSizeToFitSize:size + minimumFontSize:minimumFontSize + maximumFontSize:maximumFontSize]; + } + + return textStorage; +} + +@end diff --git a/ReactCommon/fabric/textlayoutmanager/RCTTextPrimitivesConversions.h b/ReactCommon/fabric/textlayoutmanager/RCTTextPrimitivesConversions.h new file mode 100644 index 000000000..354952d9c --- /dev/null +++ b/ReactCommon/fabric/textlayoutmanager/RCTTextPrimitivesConversions.h @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +#include +#include + +using namespace facebook::react; + +inline static NSTextAlignment RCTNSTextAlignmentFromTextAlignment(TextAlignment textAlignment) { + switch (textAlignment) { + case TextAlignment::Natural: return NSTextAlignmentNatural; + case TextAlignment::Left: return NSTextAlignmentLeft; + case TextAlignment::Right: return NSTextAlignmentRight; + case TextAlignment::Center: return NSTextAlignmentCenter; + case TextAlignment::Justified: return NSTextAlignmentJustified; + } +} + +inline static NSWritingDirection RCTNSWritingDirectionFromWritingDirection(WritingDirection writingDirection) { + switch (writingDirection) { + case WritingDirection::Natural: return NSWritingDirectionNatural; + case WritingDirection::LeftToRight: return NSWritingDirectionLeftToRight; + case WritingDirection::RightToLeft: return NSWritingDirectionRightToLeft; + } +} + +inline static RCTFontStyle RCTFontStyleFromFontStyle(FontStyle fontStyle) { + switch (fontStyle) { + case FontStyle::Normal: return RCTFontStyleNormal; + case FontStyle::Italic: return RCTFontStyleItalic; + case FontStyle::Oblique: return RCTFontStyleOblique; + } +} + +inline static RCTFontVariant RCTFontVariantFromFontVariant(FontVariant fontVariant) { + return (RCTFontVariant)fontVariant; +} + +inline static NSUnderlineStyle RCTNSUnderlineStyleFromStyleAndPattern(TextDecorationLineStyle textDecorationLineStyle, TextDecorationLinePattern textDecorationLinePattern) { + NSUnderlineStyle style = NSUnderlineStyleNone; + + switch (textDecorationLineStyle) { + case TextDecorationLineStyle::Single: + style = NSUnderlineStyle(style | NSUnderlineStyleSingle); break; + case TextDecorationLineStyle::Thick: + style = NSUnderlineStyle(style | NSUnderlineStyleThick); break; + case TextDecorationLineStyle::Double: + style = NSUnderlineStyle(style | NSUnderlineStyleDouble); break; + } + + switch (textDecorationLinePattern) { + case TextDecorationLinePattern::Solid: + style = NSUnderlineStyle(style | NSUnderlinePatternSolid); break; + case TextDecorationLinePattern::Dash: + style = NSUnderlineStyle(style | NSUnderlinePatternDash); break; + case TextDecorationLinePattern::Dot: + style = NSUnderlineStyle(style | NSUnderlinePatternDot); break; + case TextDecorationLinePattern::DashDot: + style = NSUnderlineStyle(style | NSUnderlinePatternDashDot); break; + case TextDecorationLinePattern::DashDotDot: + style = NSUnderlineStyle(style | NSUnderlinePatternDashDotDot); break; + } + + return style; +} + +inline static UIColor *RCTUIColorFromSharedColor(const SharedColor &color) { + return color ? [UIColor colorWithCGColor:color.get()] : nil; +} diff --git a/ReactCommon/fabric/textlayoutmanager/TextLayoutManager.h b/ReactCommon/fabric/textlayoutmanager/TextLayoutManager.h new file mode 100644 index 000000000..dd54980ff --- /dev/null +++ b/ReactCommon/fabric/textlayoutmanager/TextLayoutManager.h @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include + +#include +#include +#include + +namespace facebook { +namespace react { + +class TextLayoutManager; + +using SharedTextLayoutManager = std::shared_ptr; + +/* + * Cross platform facade for iOS-specific RCTTTextLayoutManager. + */ +class TextLayoutManager { +public: + TextLayoutManager(); + ~TextLayoutManager(); + + /* + * Measures `attributedString` using native text rendering infrastructure. + */ + Size measure( + AttributedString attributedString, + ParagraphAttributes paragraphAttributes, + LayoutConstraints layoutConstraints + ) const; + + /* + * Returns an opaque pointer to platform-specific TextLayoutManager. + * Is used on a native views layer to delegate text rendering to the manager. + */ + void *getNativeTextLayoutManager() const; + +private: + void *self_; +}; + +} // namespace react +} // namespace facebook diff --git a/ReactCommon/fabric/textlayoutmanager/TextLayoutManager.mm b/ReactCommon/fabric/textlayoutmanager/TextLayoutManager.mm new file mode 100644 index 000000000..f76582115 --- /dev/null +++ b/ReactCommon/fabric/textlayoutmanager/TextLayoutManager.mm @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "TextLayoutManager.h" + +#import "RCTTextLayoutManager.h" + +namespace facebook { +namespace react { + +TextLayoutManager::TextLayoutManager() { + self_ = (__bridge_retained void *)[RCTTextLayoutManager new]; +} + +TextLayoutManager::~TextLayoutManager() { + CFRelease(self_); + self_ = nullptr; +} + +void *TextLayoutManager::getNativeTextLayoutManager() const { + return self_; +} + +Size TextLayoutManager::measure( + AttributedString attributedString, + ParagraphAttributes paragraphAttributes, + LayoutConstraints layoutConstraints +) const { + RCTTextLayoutManager *textLayoutManager = (__bridge RCTTextLayoutManager *)self_; + return [textLayoutManager measureWithAttributedString:attributedString + paragraphAttributes:paragraphAttributes + layoutConstraints:layoutConstraints]; +} + +} // namespace react +} // namespace facebook diff --git a/ReactCommon/fabric/textlayoutmanager/tests/TextLayoutManagerTest.cpp b/ReactCommon/fabric/textlayoutmanager/tests/TextLayoutManagerTest.cpp new file mode 100644 index 000000000..d873c6eed --- /dev/null +++ b/ReactCommon/fabric/textlayoutmanager/tests/TextLayoutManagerTest.cpp @@ -0,0 +1,18 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include + +#include + +#include + +using namespace facebook::react; + +TEST(TextLayoutManagerTest, testSomething) { + // TODO: +}