// // ASTextKitFontSizeAdjuster.mm // Texture // // Copyright (c) Facebook, Inc. and its affiliates. All rights reserved. // Changes after 4/13/2017 are: Copyright (c) Pinterest, Inc. All rights reserved. // Licensed under Apache 2.0: http://www.apache.org/licenses/LICENSE-2.0 // #import #if AS_ENABLE_TEXTNODE #import #import #import #import #import //#define LOG(...) NSLog(__VA_ARGS__) #define LOG(...) @interface ASTextKitFontSizeAdjuster() @property (nonatomic, readonly) NSLayoutManager *sizingLayoutManager; @property (nonatomic, readonly) NSTextContainer *sizingTextContainer; @end @implementation ASTextKitFontSizeAdjuster { __weak ASTextKitContext *_context; ASTextKitAttributes _attributes; BOOL _measured; CGFloat _scaleFactor; AS::Mutex __instanceLock__; } @synthesize sizingLayoutManager = _sizingLayoutManager; @synthesize sizingTextContainer = _sizingTextContainer; - (instancetype)initWithContext:(ASTextKitContext *)context constrainedSize:(CGSize)constrainedSize textKitAttributes:(const ASTextKitAttributes &)textComponentAttributes; { if (self = [super init]) { _context = context; _constrainedSize = constrainedSize; _attributes = textComponentAttributes; } return self; } + (void)adjustFontSizeForAttributeString:(NSMutableAttributedString *)attrString withScaleFactor:(CGFloat)scaleFactor { if (scaleFactor == 1.0) return; [attrString beginEditing]; // scale all the attributes that will change the bounding box [attrString enumerateAttributesInRange:NSMakeRange(0, attrString.length) options:0 usingBlock:^(NSDictionary * _Nonnull attrs, NSRange range, BOOL * _Nonnull stop) { if (attrs[NSFontAttributeName] != nil) { UIFont *font = attrs[NSFontAttributeName]; font = [font fontWithSize:std::round(font.pointSize * scaleFactor)]; [attrString removeAttribute:NSFontAttributeName range:range]; [attrString addAttribute:NSFontAttributeName value:font range:range]; } if (attrs[NSKernAttributeName] != nil) { NSNumber *kerning = attrs[NSKernAttributeName]; [attrString removeAttribute:NSKernAttributeName range:range]; [attrString addAttribute:NSKernAttributeName value:@([kerning floatValue] * scaleFactor) range:range]; } if (attrs[NSParagraphStyleAttributeName] != nil) { NSMutableParagraphStyle *paragraphStyle = [attrs[NSParagraphStyleAttributeName] mutableCopy]; paragraphStyle.lineSpacing = (paragraphStyle.lineSpacing * scaleFactor); paragraphStyle.paragraphSpacing = (paragraphStyle.paragraphSpacing * scaleFactor); paragraphStyle.firstLineHeadIndent = (paragraphStyle.firstLineHeadIndent * scaleFactor); paragraphStyle.headIndent = (paragraphStyle.headIndent * scaleFactor); paragraphStyle.tailIndent = (paragraphStyle.tailIndent * scaleFactor); paragraphStyle.minimumLineHeight = (paragraphStyle.minimumLineHeight * scaleFactor); paragraphStyle.maximumLineHeight = (paragraphStyle.maximumLineHeight * scaleFactor); [attrString removeAttribute:NSParagraphStyleAttributeName range:range]; [attrString addAttribute:NSParagraphStyleAttributeName value:paragraphStyle range:range]; } }]; [attrString endEditing]; } - (NSUInteger)lineCountForString:(NSAttributedString *)attributedString { NSUInteger lineCount = 0; NSLayoutManager *sizingLayoutManager = [self sizingLayoutManager]; NSTextContainer *sizingTextContainer = [self sizingTextContainer]; NSTextStorage *textStorage = [[NSTextStorage alloc] initWithAttributedString:attributedString]; [textStorage addLayoutManager:sizingLayoutManager]; [sizingLayoutManager ensureLayoutForTextContainer:sizingTextContainer]; for (NSRange lineRange = { 0, 0 }; NSMaxRange(lineRange) < [sizingLayoutManager numberOfGlyphs] && lineCount <= _attributes.maximumNumberOfLines; lineCount++) { [sizingLayoutManager lineFragmentRectForGlyphAtIndex:NSMaxRange(lineRange) effectiveRange:&lineRange]; } [textStorage removeLayoutManager:sizingLayoutManager]; return lineCount; } - (CGSize)boundingBoxForString:(NSAttributedString *)attributedString { NSLayoutManager *sizingLayoutManager = [self sizingLayoutManager]; NSTextContainer *sizingTextContainer = [self sizingTextContainer]; NSTextStorage *textStorage = [[NSTextStorage alloc] initWithAttributedString:attributedString]; [textStorage addLayoutManager:sizingLayoutManager]; [sizingLayoutManager ensureLayoutForTextContainer:sizingTextContainer]; CGRect textRect = [sizingLayoutManager boundingRectForGlyphRange:NSMakeRange(0, [textStorage length]) inTextContainer:sizingTextContainer]; [textStorage removeLayoutManager:sizingLayoutManager]; return textRect.size; } - (NSLayoutManager *)sizingLayoutManager { AS::MutexLocker l(__instanceLock__); if (_sizingLayoutManager == nil) { _sizingLayoutManager = [[ASLayoutManager alloc] init]; _sizingLayoutManager.usesFontLeading = NO; if (_sizingTextContainer == nil) { // make this text container unbounded in height so that the layout manager will compute the total // number of lines and not stop counting when height runs out. _sizingTextContainer = [[NSTextContainer alloc] initWithSize:CGSizeMake(_constrainedSize.width, CGFLOAT_MAX)]; _sizingTextContainer.lineFragmentPadding = 0; // use 0 regardless of what is in the attributes so that we get an accurate line count _sizingTextContainer.maximumNumberOfLines = 0; _sizingTextContainer.lineBreakMode = _attributes.lineBreakMode; _sizingTextContainer.exclusionPaths = _attributes.exclusionPaths; } [_sizingLayoutManager addTextContainer:_sizingTextContainer]; } return _sizingLayoutManager; } - (CGFloat)scaleFactor { if (_measured) { return _scaleFactor; } if ([_attributes.pointSizeScaleFactors count] == 0 || isinf(_constrainedSize.width)) { _measured = YES; _scaleFactor = 1.0; return _scaleFactor; } __block CGFloat adjustedScale = 1.0; // We add the scale factor of 1 to our scaleFactors array so that in the first iteration of the loop below, we are // actually determining if we need to scale at all. If something doesn't fit, we will continue to iterate our scale factors. NSArray *scaleFactors = [@[@(1)] arrayByAddingObjectsFromArray:_attributes.pointSizeScaleFactors]; [_context performBlockWithLockedTextKitComponents:^(NSLayoutManager *layoutManager, NSTextStorage *textStorage, NSTextContainer *textContainer) { // Check for two different situations (and correct for both) // 1. The longest word in the string fits without being wrapped // 2. The entire text fits in the given constrained size. NSString *str = textStorage.string; NSArray *words = [str componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; NSString *longestWordNeedingResize = @""; for (NSString *word in words) { if ([word length] > [longestWordNeedingResize length]) { longestWordNeedingResize = word; } } // check to see if we may need to shrink for any of these things BOOL longestWordFits = [longestWordNeedingResize length] ? NO : YES; BOOL maxLinesFits = self->_attributes.maximumNumberOfLines > 0 ? NO : YES; BOOL heightFits = isinf(self->_constrainedSize.height) ? YES : NO; CGSize longestWordSize = CGSizeZero; if (longestWordFits == NO) { NSRange longestWordRange = [str rangeOfString:longestWordNeedingResize]; NSAttributedString *attrString = [textStorage attributedSubstringFromRange:longestWordRange]; longestWordSize = [attrString boundingRectWithSize:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX) options:NSStringDrawingUsesLineFragmentOrigin context:nil].size; } // we may need to shrink for some reason, so let's iterate through our scale factors to see if we actually need to shrink // Note: the first scale factor in the array is 1.0 so will make sure that things don't fit without shrinking for (NSNumber *adjustedScaleObj in scaleFactors) { if (longestWordFits && maxLinesFits && heightFits) { break; } adjustedScale = [adjustedScaleObj floatValue]; if (longestWordFits == NO) { // we need to check the longest word to make sure it fits longestWordFits = std::ceil((longestWordSize.width * adjustedScale) <= self->_constrainedSize.width); } // if the longest word fits, go ahead and check max line and height. If it didn't fit continue to the next scale factor if (longestWordFits == YES) { // scale our string by the current scale factor NSMutableAttributedString *scaledString = [[NSMutableAttributedString alloc] initWithAttributedString:textStorage]; [[self class] adjustFontSizeForAttributeString:scaledString withScaleFactor:adjustedScale]; // check to see if this scaled string fit in the max lines if (maxLinesFits == NO) { maxLinesFits = ([self lineCountForString:scaledString] <= self->_attributes.maximumNumberOfLines); } // if max lines still doesn't fit, continue without checking that we fit in the constrained height if (maxLinesFits == YES && heightFits == NO) { // max lines fit so make sure that we fit in the constrained height. CGSize stringSize = [self boundingBoxForString:scaledString]; heightFits = (stringSize.height <= self->_constrainedSize.height); } } } }]; _measured = YES; _scaleFactor = adjustedScale; return _scaleFactor; } @end #endif