blob: f5b4296ed02f290da041220567ac7cf42d10682a [file] [log] [blame]
// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#import "ios/chrome/browser/ui/util/manual_text_framer.h"
#import <UIKit/UIKit.h>
#include "base/i18n/rtl.h"
#include "base/logging.h"
#include "base/mac/foundation_util.h"
#import "ios/chrome/browser/ui/util/core_text_util.h"
#import "ios/chrome/browser/ui/util/text_frame.h"
#import "ios/chrome/browser/ui/util/unicode_util.h"
#if !defined(__has_feature) || !__has_feature(objc_arc)
#error "This file requires ARC support."
#endif
// NOTE: When RTL text is laid out into glyph runs, the glyphs appear in the
// visual order in which they appear on screen. In other words, the glyphs are
// arranged in the reverse order of their corresponding characters in the
// original string.
namespace {
// Aligns |value| to the nearest pixel value, rounding by the function indicated
// by |function|. AlignmentFunction::CEIL should be used to align size values,
// while AlignmentFunction::FLOOR should be used to align location values.
enum class AlignmentFunction : short { CEIL = 0, FLOOR };
CGFloat AlignValueToPixel(CGFloat value, AlignmentFunction function) {
static CGFloat scale = [[UIScreen mainScreen] scale];
return function == AlignmentFunction::CEIL ? ceil(value * scale) / scale
: floor(value * scale) / scale;
}
// Returns an NSArray of NSAttributedStrings corresponding to newline-separated
// paragraphs within |string|.
NSArray* GetParagraphStringsForString(NSAttributedString* string) {
NSMutableArray* paragraph_strings = [NSMutableArray array];
NSCharacterSet* newline_char_set = [NSCharacterSet newlineCharacterSet];
NSUInteger string_length = string.string.length;
NSRange remaining_range = NSMakeRange(0, string_length);
while (remaining_range.location < string_length) {
NSRange newline_range =
[string.string rangeOfCharacterFromSet:newline_char_set
options:0
range:remaining_range];
NSRange paragraph_range = NSMakeRange(0, 0);
if (newline_range.location == NSNotFound) {
// There's no newline in the remaining portion of the string.
paragraph_range = remaining_range;
remaining_range = NSMakeRange(string_length, 0);
} else {
// A newline character was encountered. Compute approximate text lines
// for the substring within |remaining_range| up to the newline.
NSUInteger newline_end = newline_range.location + newline_range.length;
paragraph_range = NSMakeRange(remaining_range.location,
newline_end - remaining_range.location);
remaining_range.location = newline_end;
remaining_range.length = string_length - remaining_range.location;
}
// Create an attributed substring for the current paragraph and add it to
// |paragraphs|.
[paragraph_strings
addObject:[string attributedSubstringFromRange:paragraph_range]];
}
return paragraph_strings;
}
} // namespace
#pragma mark - ManualTextFrame
// A TextFrame implementation that is manually created by ManualTextFramer.
@interface ManualTextFrame : NSObject<TextFrame> {
// Backing objects for properties of the same name.
NSAttributedString* _string;
NSMutableArray* _lines;
}
// Designated initializer.
- (instancetype)initWithString:(NSAttributedString*)string
inBounds:(CGRect)bounds NS_DESIGNATED_INITIALIZER;
- (instancetype)init NS_UNAVAILABLE;
// Creates a FramedLine out of |line|, |stringRange|, and |origin|, then adds it
// to |lines|.
- (void)addFramedLineWithLine:(CTLineRef)line
stringRange:(NSRange)stringRange
origin:(CGPoint)origin;
// Redefine property as readwrite.
@property(nonatomic, readwrite) NSRange framedRange;
@end
@implementation ManualTextFrame
@synthesize framedRange = _framedRange;
@synthesize bounds = _bounds;
- (instancetype)initWithString:(NSAttributedString*)string
inBounds:(CGRect)bounds {
if ((self = [super init])) {
DCHECK(string.string.length);
_string = string;
_bounds = bounds;
_lines = [[NSMutableArray alloc] init];
}
return self;
}
#pragma mark Accessors
- (NSAttributedString*)string {
return _string;
}
- (NSArray*)lines {
return _lines;
}
#pragma mark Private
- (void)addFramedLineWithLine:(CTLineRef)line
stringRange:(NSRange)stringRange
origin:(CGPoint)origin {
FramedLine* framedLine = [[FramedLine alloc] initWithLine:line
stringRange:stringRange
origin:origin];
[_lines addObject:framedLine];
}
@end
#pragma mark - ManualTextFramer Private Interface
@interface ManualTextFramer ()
// The string passed upon initialization.
@property(strong, nonatomic, readonly) NSAttributedString* string;
// The bounds passed upon initialization.
@property(nonatomic, readonly) CGRect bounds;
// The width of the bounds passed upon initialization.
@property(nonatomic, readonly) CGFloat boundingWidth;
// The remaining height into which text can be framed.
@property(nonatomic, assign) CGFloat remainingHeight;
// The text frame constructed by |-frameText|.
@property(strong, nonatomic, readonly) ManualTextFrame* manualTextFrame;
// Creates a ManualTextFrame and assigns it to |_manualTextFrame|. Returns YES
// if a new text frame was successfully created.
- (BOOL)setupManualTextFrame;
@end
#pragma mark - ParagraphFramer
// ManualTextFramer subclass that frames a single paragraph. A paragraph is
// defined as an NSAttributedString which contains either zero newlines or one
// newline as its last character.
@interface ParagraphFramer : ManualTextFramer {
// Backing objects for properties of the same name.
base::ScopedCFTypeRef<CTLineRef> _line;
}
// The CTLine created from |string|.
@property(nonatomic, readonly) CTLineRef line;
// The effective text alignment for |line|.
@property(nonatomic, readonly) NSTextAlignment effectiveAlignment;
// Character set containing characters that are appropriate for line endings.
// These characters include whitespaces and newlines (denoting a word boundary),
// in addition to line-ending characters like hyphens, em dashes, and en dashes.
@property(strong, nonatomic, readonly) NSCharacterSet* lineEndSet;
// The index of the current run that is being framed. Setting |runIdx| also
// updates |currentRun| and |currentGlyphCount|.
@property(nonatomic, assign) CFIndex runIdx;
// The CTRun corresponding with |runIdx| in |line|.
@property(nonatomic, readonly) CTRunRef currentRun;
// The glyph count in |currentRun|.
@property(nonatomic, readonly) CFIndex currentGlyphCount;
// The number of glyphs in |currentRun| that have been successfully framed.
@property(nonatomic, assign) CFIndex framedGlyphCount;
// The range in |string| that has successfully been framed for the current line.
@property(nonatomic, assign) NSRange currentLineRange;
// The width of the typographic bounds for the glyphs framed on the current
// line. This is the width of the substring of |string| corresponding to
// |currentLineRange|.
@property(nonatomic, assign) CGFloat currentLineWidth;
// The width of the trailing whitespace for the current line. This whitespace
// is not counted against the line width if it's the end of the line, but needs
// to be added in if non-whitespace characters from subsequent runs fit on the
// same line.
@property(nonatomic, assign) CGFloat currentWhitespaceWidth;
// Whether the paragraph's writing direction is in RTL.
@property(nonatomic, readonly) BOOL isRTL;
// Either 1 or -1 depending on |isRTL|.
@property(nonatomic, readonly) CFIndex incrementAmount;
// Glyphs are laid out differently for RTL and LTR languages (see note at top of
// file). These functions return a range with |range|'s length incremented or
// decremented and an updated location that would include the next glyph in the
// trailing direction.
- (CFRange)incrementRange:(CFRange)range byAmount:(CFIndex)amount;
- (CFRange)incrementRange:(CFRange)range;
- (CFRange)decrementRange:(CFRange)range;
// Updates |range| such that its trailing glyph index is |trailingGlyphIdx|.
- (CFRange)updateRange:(CFRange)range
forTrailingGlyphIdx:(CFIndex)trailingGlyphIdx;
// Returns the index of the trailing glyph in |range| for |currentRun|.
- (CFIndex)trailingGlyphIdxForRange:(CFRange)range;
// Returns the index of the leading or trailing glyph in |currentRun|.
- (CFIndex)trailingGlyphIdxForCurrentRun;
// Manually frames the glyphs in |currentRun| following |framedGlyphCount|.
// This function updates |framedGlyphCount|, |currentLineWidth|, and
// |currentLineRange|.
- (void)frameCurrentRun;
// Returns the character associated with the glyph at |glyphIdx| in
// |currentRun|.
- (unichar)charForGlyphAtIdx:(CFIndex)glyphIdx;
// Returns the index within the original string corresponding to the glyph at
// |glyphIdx| in |currentRun|.
- (CFIndex)stringIdxForGlyphAtIdx:(CFIndex)glyphIdx;
// Returns YES if |runIdx| is within the range of |line|'s glyph runs array.
- (BOOL)runIdxIsValid:(CFIndex)runIdx;
// Returns YES if |glyphIdx| is within [0, |currentGlyphCount|).
- (BOOL)glyphIdxIsValid:(CFIndex)glyphIdx;
// Creates a line from |currentLineRange| and adds it to |lines|.
- (void)addCurrentLine;
// Returns the baselines origin for the current line. This function depends on
// |currentLineRange|, |currentLineWidth|, and |remainingHeight|, and must be
// called before updating those bookkeeping variables when adding the line.
- (CGPoint)originForCurrentLine;
@end
@implementation ParagraphFramer
@synthesize effectiveAlignment = _effectiveTextAlignment;
@synthesize runIdx = _runIdx;
@synthesize currentRun = _currentRun;
@synthesize currentGlyphCount = _currentGlyphCount;
@synthesize framedGlyphCount = _framedGlyphCount;
@synthesize currentLineRange = _currentLineRange;
@synthesize currentLineWidth = _currentLineWidth;
@synthesize currentWhitespaceWidth = _currentWhitespaceWidth;
@synthesize isRTL = _isRTL;
@synthesize lineEndSet = _lineEndSet;
- (instancetype)initWithString:(NSAttributedString*)string
inBounds:(CGRect)bounds {
if ((self = [super initWithString:string inBounds:bounds])) {
NSRange newlineRange = [string.string
rangeOfCharacterFromSet:[NSCharacterSet newlineCharacterSet]];
DCHECK(newlineRange.location == NSNotFound ||
newlineRange.location == string.string.length - 1);
CTLineRef line =
CTLineCreateWithAttributedString(base::mac::NSToCFCast(string));
_line.reset(line);
_effectiveTextAlignment = core_text_util::GetEffectiveTextAlignment(string);
NSWritingDirection direction =
core_text_util::GetEffectiveWritingDirection(string);
_isRTL = direction == NSWritingDirectionRightToLeft;
}
return self;
}
- (void)frameText {
if (![self setupManualTextFrame])
return;
self.runIdx =
self.isRTL ? CFArrayGetCount(CTLineGetGlyphRuns(self.line)) - 1 : 0;
while (self.currentRun) {
NSRange runStringRange =
core_text_util::GetStringRangeForRun(self.currentRun);
DCHECK_NE(runStringRange.location, static_cast<NSUInteger>(NSNotFound));
DCHECK_NE(runStringRange.length, 0U);
CGFloat runLineHeight =
core_text_util::GetLineHeight(self.string, runStringRange);
// Count of the number of times the framing process (-frameCurrentRun) has
// "stalled" -- run without changing the total number of glyphs framed. In
// some cases the process may stall once at the end of a run, but if it
// stalls twice, it won't make any further progress and should halt.
NSUInteger stallCount = 0;
// Loop as long as framed glyph count is less that the total glyph count,
// and the framer is making progress.
while (self.framedGlyphCount < self.currentGlyphCount && stallCount < 2U) {
// Stop framing glyphs if there is not enough vertical space for the run.
if (self.remainingHeight < runLineHeight)
break;
CFIndex initialFramedGlyphCount = self.framedGlyphCount;
[self frameCurrentRun];
if (self.framedGlyphCount == initialFramedGlyphCount)
stallCount++;
if (self.framedGlyphCount < self.currentGlyphCount) {
// The entire run didn't fit onto the current line, so create a CTLine
// from |currentLineRange| and add it to |lines|.
[self addCurrentLine];
}
}
self.runIdx += self.incrementAmount;
}
// Add the final line.
[self addCurrentLine];
// Update |manualTextFrame|'s |framedRange|.
self.manualTextFrame.framedRange =
NSMakeRange(0, self.currentLineRange.location);
}
#pragma mark Accessors
- (CTLineRef)line {
return _line.get();
}
- (NSCharacterSet*)lineEndSet {
if (!_lineEndSet) {
NSMutableCharacterSet* lineEndSet =
[NSMutableCharacterSet whitespaceAndNewlineCharacterSet];
[lineEndSet addCharactersInString:@"-\u2013\u2014"];
_lineEndSet = lineEndSet;
}
return _lineEndSet;
}
- (void)setRunIdx:(CFIndex)runIdx {
_runIdx = runIdx;
self.framedGlyphCount = 0;
if ([self runIdxIsValid:runIdx]) {
NSArray* runs = base::mac::CFToNSCast(CTLineGetGlyphRuns(self.line));
_currentRun = (__bridge CTRunRef)(runs[_runIdx]);
_currentGlyphCount = CTRunGetGlyphCount(self.currentRun);
} else {
_currentRun = nullptr;
_currentGlyphCount = 0;
}
}
- (CFIndex)incrementAmount {
return self.isRTL ? -1 : 1;
}
#pragma mark Private
- (CFRange)incrementRange:(CFRange)range byAmount:(CFIndex)amount {
CFRange incrementedRange = range;
incrementedRange.length += amount;
if (self.isRTL)
incrementedRange.location += self.incrementAmount * amount;
return incrementedRange;
}
- (CFRange)incrementRange:(CFRange)range {
return [self incrementRange:range byAmount:1];
}
- (CFRange)decrementRange:(CFRange)range {
return [self incrementRange:range byAmount:-1];
}
- (CFRange)updateRange:(CFRange)range
forTrailingGlyphIdx:(CFIndex)trailingGlyphIdx {
DCHECK(self.isRTL ? trailingGlyphIdx <= range.location + range.length
: trailingGlyphIdx >= range.location);
DCHECK([self glyphIdxIsValid:trailingGlyphIdx]);
CFIndex currentTrailingGlyphIdx = [self trailingGlyphIdxForRange:range];
CFIndex updateAmount = self.isRTL
? currentTrailingGlyphIdx - trailingGlyphIdx
: trailingGlyphIdx - currentTrailingGlyphIdx;
return [self incrementRange:range byAmount:updateAmount];
}
- (CFIndex)trailingGlyphIdxForRange:(CFRange)range {
if (self.isRTL)
return range.location;
return range.location + range.length - 1;
}
- (CFIndex)trailingGlyphIdxForCurrentRun {
return self.isRTL ? 0 : self.currentGlyphCount - 1;
}
- (void)frameCurrentRun {
DCHECK(self.currentRun);
DCHECK_LT(self.framedGlyphCount, self.currentGlyphCount);
DCHECK_LT(self.currentLineWidth, self.boundingWidth);
// Calculate the range that will fit in the remaining portion of the line.
NSCharacterSet* whitespaceSet =
[NSCharacterSet whitespaceAndNewlineCharacterSet];
CFIndex startGlyphIdx = self.isRTL
? self.currentGlyphCount - self.framedGlyphCount
: self.framedGlyphCount;
CFRange remainingRunRange =
CFRangeMake(self.isRTL ? 0 : startGlyphIdx,
self.currentGlyphCount - self.framedGlyphCount);
CFRange range = CFRangeMake(startGlyphIdx, 0);
while (remainingRunRange.length > 0) {
// Find the range for the next word that can be added to the line. If no
// delimiters were found, frame the rest of the run.
CFIndex delimIdx = core_text_util::GetGlyphIdxForCharInSet(
self.currentRun, remainingRunRange, self.string, self.lineEndSet);
if (delimIdx == kCFNotFound)
delimIdx = [self trailingGlyphIdxForCurrentRun];
CFRange wordGlyphRange =
[self updateRange:remainingRunRange forTrailingGlyphIdx:delimIdx];
CFIndex wordFramedGlyphCount = wordGlyphRange.length;
// Trim any whitespace and record its width.
CGFloat wordTrailingWhitespaceWidth = 0.0;
if ([whitespaceSet characterIsMember:[self charForGlyphAtIdx:delimIdx]]) {
wordTrailingWhitespaceWidth =
core_text_util::GetGlyphWidth(self.currentRun, delimIdx);
wordGlyphRange = [self decrementRange:wordGlyphRange];
}
// Check if the word will fit on the line.
CGFloat wordWidth =
core_text_util::GetRunWidthWithRange(self.currentRun, wordGlyphRange);
CGFloat cumulativeLineWidth =
self.currentLineWidth + self.currentWhitespaceWidth + wordWidth;
if (cumulativeLineWidth <= self.boundingWidth) {
// The word at |wordGlyphRange| fits on the line.
self.currentLineWidth = cumulativeLineWidth;
self.framedGlyphCount += wordFramedGlyphCount;
self.currentWhitespaceWidth = wordTrailingWhitespaceWidth;
remainingRunRange.length -= wordFramedGlyphCount;
if (!self.isRTL)
remainingRunRange.location += wordFramedGlyphCount;
range = [self incrementRange:range byAmount:wordFramedGlyphCount];
} else {
break;
}
}
// Early return if no glyphs were framed.
if (!range.length)
return;
// Use the string index of the next glyph to determine the string range for
// the current line, since a glyph may correspond with multiple characters
// when ligatures are used.
CFIndex nextGlyphIdx =
[self trailingGlyphIdxForRange:range] + self.incrementAmount;
CFIndex nextGlyphStringIdx;
if ([self glyphIdxIsValid:nextGlyphIdx]) {
nextGlyphStringIdx = [self stringIdxForGlyphAtIdx:nextGlyphIdx];
} else {
CFRange runStringRange = CTRunGetStringRange(self.currentRun);
nextGlyphStringIdx = runStringRange.location + runStringRange.length;
}
self.currentLineRange =
NSMakeRange(self.currentLineRange.location,
nextGlyphStringIdx - self.currentLineRange.location);
}
- (unichar)charForGlyphAtIdx:(CFIndex)glyphIdx {
DCHECK([self glyphIdxIsValid:glyphIdx]);
return [self.string.string
characterAtIndex:[self stringIdxForGlyphAtIdx:glyphIdx]];
}
- (CFIndex)stringIdxForGlyphAtIdx:(CFIndex)glyphIdx {
DCHECK([self glyphIdxIsValid:glyphIdx]);
CFIndex stringIdx = 0;
CTRunGetStringIndices(self.currentRun, CFRangeMake(glyphIdx, 1), &stringIdx);
return stringIdx;
}
- (BOOL)runIdxIsValid:(CFIndex)runIdx {
NSArray* runs = base::mac::CFToNSCast(CTLineGetGlyphRuns(self.line));
return runIdx >= 0 && runIdx < static_cast<CFIndex>(runs.count);
}
- (BOOL)glyphIdxIsValid:(CFIndex)glyphIdx {
return glyphIdx >= 0 && glyphIdx < self.currentGlyphCount;
}
- (void)addCurrentLine {
// Don't attempt to add a line if |currentLineRange| is empty.
if (!self.currentLineRange.length)
return;
// Add the new line and its corresponding string range and baseline origin.
NSAttributedString* currentLineString =
[self.string attributedSubstringFromRange:self.currentLineRange];
CTLineRef currentLine = CTLineCreateWithAttributedString(
base::mac::NSToCFCast(currentLineString));
[self.manualTextFrame addFramedLineWithLine:currentLine
stringRange:self.currentLineRange
origin:[self originForCurrentLine]];
CFRelease(currentLine);
// Update bookkeeping variables for next line.
CGFloat usedHeight =
core_text_util::GetLineHeight(self.string, self.currentLineRange) +
core_text_util::GetLineSpacing(self.string, self.currentLineRange);
self.currentLineRange = NSMakeRange(
self.currentLineRange.location + self.currentLineRange.length, 0);
self.currentLineWidth = 0;
self.currentWhitespaceWidth = 0;
self.remainingHeight -= usedHeight;
}
- (CGPoint)originForCurrentLine {
CGPoint origin = CGPointZero;
CGFloat alignedWidth =
AlignValueToPixel(self.currentLineWidth, AlignmentFunction::CEIL);
switch (self.effectiveAlignment) {
case NSTextAlignmentLeft:
// Left-aligned lines begin at 0.0.
break;
case NSTextAlignmentRight:
origin.x = AlignValueToPixel(self.boundingWidth - alignedWidth,
AlignmentFunction::FLOOR);
break;
case NSTextAlignmentCenter:
origin.x = AlignValueToPixel((self.boundingWidth - alignedWidth) / 2.0,
AlignmentFunction::FLOOR);
break;
default:
// Only left, right, and center effective alignment is supported.
NOTREACHED();
break;
}
UIFont* font = [self.string attribute:NSFontAttributeName
atIndex:self.currentLineRange.location
effectiveRange:nullptr];
CGFloat lineHeight =
core_text_util::GetLineHeight(self.string, self.currentLineRange);
origin.y =
AlignValueToPixel(self.remainingHeight - lineHeight - font.descender,
AlignmentFunction::FLOOR);
return origin;
}
@end
#pragma mark - ManualTextFramer
@implementation ManualTextFramer
@synthesize bounds = _bounds;
@synthesize boundingWidth = _boundingWidth;
@synthesize remainingHeight = _remainingHeight;
@synthesize string = _string;
@synthesize manualTextFrame = _manualTextFrame;
- (instancetype)initWithString:(NSAttributedString*)string
inBounds:(CGRect)bounds {
if ((self = [super init])) {
DCHECK(string.string.length);
_string = string;
_bounds = bounds;
_boundingWidth = CGRectGetWidth(bounds);
_remainingHeight = CGRectGetHeight(bounds);
}
return self;
}
- (void)frameText {
if (![self setupManualTextFrame])
return;
NSRange framedRange = NSMakeRange(0, 0);
NSArray* paragraphs = GetParagraphStringsForString(self.string);
NSUInteger stringRangeOffset = 0;
for (NSAttributedString* paragraph in paragraphs) {
// Frame each paragraph using a ParagraphFramer, then update bookkeeping
// variables for the top-level ManualTextFramer.
CGRect remainingBounds =
CGRectMake(0, 0, self.boundingWidth, self.remainingHeight);
ParagraphFramer* framer =
[[ParagraphFramer alloc] initWithString:paragraph
inBounds:remainingBounds];
[framer frameText];
id<TextFrame> frame = [framer textFrame];
DCHECK(frame);
framedRange.length += frame.framedRange.length;
CGFloat paragraphHeight = 0.0;
for (FramedLine* line in frame.lines) {
NSRange lineRange = line.stringRange;
lineRange.location += stringRangeOffset;
[self.manualTextFrame addFramedLineWithLine:line.line
stringRange:lineRange
origin:line.origin];
paragraphHeight += core_text_util::GetLineHeight(self.string, lineRange) +
core_text_util::GetLineSpacing(self.string, lineRange);
}
self.remainingHeight -= paragraphHeight;
stringRangeOffset += paragraph.string.length;
}
self.manualTextFrame.framedRange = framedRange;
}
#pragma mark Accessors
- (id<TextFrame>)textFrame {
return _manualTextFrame;
}
#pragma mark Private
- (BOOL)setupManualTextFrame {
if (_manualTextFrame)
return NO;
_manualTextFrame =
[[ManualTextFrame alloc] initWithString:self.string inBounds:self.bounds];
return YES;
}
@end