/* * CPTypesetter.j * AppKit * * Created by Daniel Boehringer on 27/12/2013. * All modifications copyright Daniel Boehringer 2013. * Extensive code formatting and review by Andrew Hankinson * Based on original work by * Created by Emmanuel Maillard on 27/02/2010. * Copyright Emmanuel Maillard 2010. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ @import @import "CPParagraphStyle.j" @import "CPTextStorage.j" @import "CPFont.j" @global CPBaselineOffsetAttributeName @global CPSuperscriptAttributeName // forward declare these classes for type matching @class CPLayoutManager @class CPTextContainer @class CPTextView /* CPTypesetterControlCharacterAction */ CPTypesetterZeroAdvancementAction = 1 << 0; CPTypesetterWhitespaceAction = 1 << 1; CPSTypesetterHorizontalTabAction = 1 << 2; CPTypesetterLineBreakAction = 1 << 3; CPTypesetterParagraphBreakAction = 1 << 4; CPTypesetterContainerBreakAction = 1 << 5; var CPSystemTypesetterFactory, _sharedSimpleTypesetter, _CPTypesetterCachedDecimalSeparator = nil; /* Returns the decimal separator of the user's locale, which a decimal tab stop aligns on. */ var _CPTypesetterDecimalSeparator = function() { if (!_CPTypesetterCachedDecimalSeparator) { var formatted = (1.5).toLocaleString(); _CPTypesetterCachedDecimalSeparator = formatted.length === 3 ? formatted.charAt(1) : @"."; } return _CPTypesetterCachedDecimalSeparator; }; @implementation CPTypesetter : CPObject // MARK: - // MARK: Class methods + (void)initialize { [CPTypesetter _setSystemTypesetterFactory:[CPSimpleTypesetter class]]; } + (id)sharedSystemTypesetter { return [CPSystemTypesetterFactory sharedInstance]; } + (void)_setSystemTypesetterFactory:(Class)aClass { CPSystemTypesetterFactory = aClass; } - (CPTypesetterControlCharacterAction)actionForControlCharacterAtIndex:(unsigned)charIndex { return CPTypesetterZeroAdvancementAction; } - (CPLayoutManager)layoutManager { return nil; } - (CPTextContainer)currentTextContainer { return nil; } - (CPArray)textContainers { return nil; } - (void)layoutGlyphsInLayoutManager:(CPLayoutManager)layoutManager startingAtGlyphIndex:(unsigned)startGlyphIndex maxNumberOfLineFragments:(unsigned)maxNumLines nextGlyphIndex:(UIntegerReference)nextGlyph { _CPRaiseInvalidAbstractInvocation(self, _cmd); } @end @implementation CPSimpleTypesetter : CPTypesetter { CPLayoutManager _layoutManager @accessors(property=layoutManager); CPTextContainer _currentTextContainer @accessors(property=currentTextContainer); CPTextStorage _textStorage; CPRange _attributesRange; CPDictionary _currentAttributes; CPParagraphStyle _currentParagraph; float _lineHeight; float _lineBase; float _lineWidth; unsigned _indexOfCurrentContainer; CPArray _lineFragments; } // MARK: - // MARK: Class methods + (id)sharedInstance { if (!_sharedSimpleTypesetter) _sharedSimpleTypesetter = [[CPSimpleTypesetter alloc] init]; return _sharedSimpleTypesetter; } - (CPArray)textContainers { return [_layoutManager textContainers]; } // Retrieves correct CPTextTab stop accounting for custom stops and default intervals - (CPTextTab)textTabForWidth:(double)aWidth writingDirection:(CPWritingDirection)direction { var tabStops = [_currentParagraph tabStops], defaultInterval = [_currentParagraph defaultTabInterval] || 28.0; var l = tabStops ? [tabStops count] : 0; // 1. If custom tab stops exist ahead of current position, use the first one encountered if (l > 0) { for (var i = 0; i < l; i++) { var tab = [tabStops objectAtIndex:i]; if ([tab location] > aWidth) return tab; } } // 2. Otherwise (or when all custom tab stops are behind the text), advance to the next default interval var nextLocation = (Math.floor(aWidth / defaultInterval) + 1) * defaultInterval; return [[CPTextTab alloc] initWithType:CPLeftTextAlignment location:nextLocation]; } - (BOOL)_flushRange:(CPRange)lineRange lineOrigin:(CGPoint)lineOrigin currentContainer:(CPTextContainer)aContainer advancements:(CPArray)advancements lineCount:(unsigned)lineCount sameLine:(BOOL)sameLine wordWrapped:(BOOL)wordWrapped rightMargin:(float)rightMargin { // Line positions are computed inside the line fragment padding. The padding is added here, where // the line fragment rectangle is stored, and alignment uses the width inside the padding. var padding = [aContainer lineFragmentPadding], myX = 0, rect = CGRectMake(lineOrigin.x + padding, lineOrigin.y, _lineWidth, _lineHeight); // As in Cocoa, text is aligned between the indents, and whitespace at the end of the line hangs // outside the alignment: it does not count in the line's visible width. var theString = [_textStorage string], availableWidth = rightMargin - lineOrigin.x, visibleLength = lineRange.length, visibleWidth = _lineWidth; while (visibleLength > 0) { var trailingCharCode = theString.charCodeAt(lineRange.location + visibleLength - 1); if (trailingCharCode !== 32 && trailingCharCode !== 10 && trailingCharCode !== 13) break; visibleLength--; visibleWidth -= advancements[visibleLength].width; } [_layoutManager _appendNewLineFragmentInTextContainer:_currentTextContainer forGlyphRange:lineRange]; var fragment = [_layoutManager._lineFragments lastObject]; fragment._isLast = !sameLine; _lineFragments.push(fragment); [_layoutManager setLineFragmentRect:rect forGlyphRange:lineRange usedRect:rect]; switch ([_currentParagraph alignment]) { case CPLeftTextAlignment: myX = 0; break; case CPCenterTextAlignment: myX = (availableWidth - visibleWidth) / 2; break; case CPRightTextAlignment: myX = availableWidth - visibleWidth; break; case CPJustifiedTextAlignment: // Only a line that wraps is justified. The last line of a paragraph, and the text // before a tab on the same line, stay left aligned, as in Cocoa. The spare width is // spread evenly over the spaces inside the visible part of the line. The line fragment // draws its spans with the same extra word spacing. if (wordWrapped && !sameLine) { var spaceIndexes = []; for (var i = 0; i < visibleLength; i++) { var charCode = theString.charCodeAt(lineRange.location + i); if (charCode === 32 || charCode === 160) spaceIndexes.push(i); } var spaceCount = spaceIndexes.length, extraWidth = spaceCount ? (availableWidth - visibleWidth) / spaceCount : 0.0; if (extraWidth > 0.0) { for (var i = 0; i < spaceCount; i++) advancements[spaceIndexes[i]].width += extraWidth; fragment._wordSpacing = extraWidth; } } break; } [_layoutManager setLocation:CGPointMake(myX, _lineBase) forStartOfGlyphRange:lineRange]; [_layoutManager _setAdvancements:advancements forGlyphRange:lineRange]; // fix the _lineFragments when fontsizes differ var l = _lineFragments.length; for (var i = 0 ; i < l ; i++) [_lineFragments[i] _adjustForHeight:_lineHeight]; if (!lineCount) // do not rescue on first line return NO; if (aContainer._inResizing) return NO; return ([_layoutManager _rescuingInvalidFragmentsWasPossibleForGlyphRange:lineRange]); } - (void)layoutGlyphsInLayoutManager:(CPLayoutManager)layoutManager startingAtGlyphIndex:(unsigned)glyphIndex maxNumberOfLineFragments:(unsigned)maxNumLines nextGlyphIndex:(UIntegerReference)nextGlyph { var textContainers = [layoutManager textContainers], textContainersCount = [textContainers count]; _layoutManager = layoutManager; _textStorage = [_layoutManager textStorage]; _indexOfCurrentContainer = MAX(0, [textContainers indexOfObject:[_layoutManager textContainerForGlyphAtIndex:glyphIndex effectiveRange:nil withoutAdditionalLayout:YES] inRange:CPMakeRange(0, textContainersCount)]); _currentTextContainer = textContainers[_indexOfCurrentContainer]; _attributesRange = CPMakeRange(0, 0); _lineHeight = 0; _lineBase = 0; _lineWidth = 0; // containerSizeWidth is the width inside the line fragment padding at both ends. var containerSize = [_currentTextContainer containerSize], lineFragmentPadding = [_currentTextContainer lineFragmentPadding], containerSizeWidth = containerSize.width - 2.0 * lineFragmentPadding, containerSizeHeight = containerSize.height, lineRange = CPMakeRange(glyphIndex, 0), wrapRange = CPMakeRange(0, 0), wrapWidth = 0, isNewline = NO, isTabStop = NO, isAttachment = NO, isWordWrapped = NO, numberOfGlyphs = [_textStorage length], leading, numLines = 0, theString = [_textStorage string], lineOrigin, ascent, descent, advancements = [], prevRangeWidth = 0, measuringRange = CPMakeRange(glyphIndex, 0), currentAnchor = 0, currentFont, currentFontLineHeight, previousFont, currentParagraphMinimumLineHeight, currentParagraphMaximumLineHeight, currentParagraphLineSpacing; // Track physical line starts to prevent overwriting lineOrigin.x in tab segments var isStartOfPhysicalLine = YES; // Track paragraph indents and margins var isFirstLineOfLayout = YES, isFirstLineOfParagraph = YES, rightMargin = containerSizeWidth; if (glyphIndex > 0) { // Stored line fragment rectangles include the padding, which the layout below does not. lineOrigin = CGPointCreateCopy([_layoutManager lineFragmentRectForGlyphAtIndex:glyphIndex effectiveRange:nil].origin); lineOrigin.x -= lineFragmentPadding; } else if ([_layoutManager extraLineFragmentTextContainer]) lineOrigin = CGPointMake(0, [_layoutManager extraLineFragmentUsedRect].origin.y); else lineOrigin = CGPointMake(0, 0); [_layoutManager _removeInvalidLineFragments]; if (![_textStorage length]) return; _lineFragments = []; for (; numLines != maxNumLines && glyphIndex < numberOfGlyphs; glyphIndex++) { // check whether there is any change in the attributes from here on if (!CPLocationInRange(glyphIndex, _attributesRange)) { _currentAttributes = [_textStorage attributesAtIndex:glyphIndex effectiveRange:_attributesRange]; currentFont = [_currentAttributes objectForKey:CPFontAttributeName]; _currentParagraph = [_currentAttributes objectForKey:CPParagraphStyleAttributeName] || [CPParagraphStyle defaultParagraphStyle]; currentParagraphMinimumLineHeight = [_currentParagraph minimumLineHeight]; currentParagraphMaximumLineHeight = [_currentParagraph maximumLineHeight]; currentParagraphLineSpacing = [_currentParagraph lineSpacing]; // Recalculate right margin on paragraph style change var tailIndent = [_currentParagraph tailIndent]; if (tailIndent > 0.0) rightMargin = tailIndent; else if (tailIndent < 0.0) rightMargin = containerSizeWidth + tailIndent; else rightMargin = containerSizeWidth; // If we are at the start of a physical line, we update lineOrigin.x if (isStartOfPhysicalLine) { if (glyphIndex > 0) { var prevChar = theString.charCodeAt(glyphIndex - 1); isFirstLineOfParagraph = (prevChar === 10 || prevChar === 13); } else { isFirstLineOfParagraph = YES; } lineOrigin.x = isFirstLineOfParagraph ? [_currentParagraph firstLineHeadIndent] : [_currentParagraph headIndent]; isFirstLineOfLayout = NO; } // Handle the layout's very first line indentation if (isFirstLineOfLayout) { if (glyphIndex > 0) { var prevChar = theString.charCodeAt(glyphIndex - 1); isFirstLineOfParagraph = (prevChar === 10 || prevChar === 13); } else { isFirstLineOfParagraph = YES; } lineOrigin.x = isFirstLineOfParagraph ? [_currentParagraph firstLineHeadIndent] : [_currentParagraph headIndent]; isFirstLineOfLayout = NO; } if (!currentFont) currentFont = [_textStorage font] || [CPFont systemFontOfSize:12.0]; // Safely retrieve and validate CPBaselineOffsetAttributeName var baselineOffset = [_currentAttributes objectForKey:CPBaselineOffsetAttributeName]; if (baselineOffset === nil || baselineOffset === undefined || typeof baselineOffset !== "number") baselineOffset = 0.0; // Safely retrieve and validate CPSuperscriptAttributeName var superscript = [_currentAttributes objectForKey:CPSuperscriptAttributeName]; if (superscript === nil || superscript === undefined || typeof superscript !== "number") superscript = 0; if (superscript !== 0) { var size = [currentFont size], scaledSize = size * 0.65, fontName = [currentFont familyName], isBold = [currentFont isBold], isItalic = [currentFont isItalic]; currentFont = [CPFont _fontWithName:fontName size:scaledSize bold:isBold italic:isItalic]; if (baselineOffset === 0.0) { if (superscript > 0) baselineOffset = size * 0.35; else baselineOffset = -size * 0.15; } } var fontAscent = [currentFont ascender] || 0.0, fontDescent = [currentFont descender] || 0.0; ascent = fontAscent + baselineOffset; descent = fontDescent + baselineOffset; leading = (fontAscent - fontDescent) * 0.2; // FAKE leading currentFontLineHeight = ascent - descent + leading; if (previousFont !== currentFont) { measuringRange = CPMakeRange(glyphIndex, 0); currentAnchor = prevRangeWidth; previousFont = currentFont; } } // Clean bounds logic to prevent NaN and layout calculation overhead var currentAscent = (ascent === undefined || isNaN(ascent)) ? 0.0 : ascent, currentLineHeight = (currentFontLineHeight === undefined || isNaN(currentFontLineHeight)) ? 12.0 : currentFontLineHeight; if (currentLineHeight > _lineHeight) _lineHeight = currentLineHeight; if (currentAscent > _lineBase) _lineBase = currentAscent; lineRange.length++; measuringRange.length++; // We are processing characters, so we are no longer at the start of a physical line isStartOfPhysicalLine = NO; var currentCharCode = theString.charCodeAt(glyphIndex), rangeWidth = [theString.substr(measuringRange.location, measuringRange.length) sizeWithFont:currentFont inWidth:NULL].width + currentAnchor; switch (currentCharCode) { case CPAttachmentCharacter: { var attributes = [_textStorage attributesAtIndex:glyphIndex effectiveRange:nil], view = [attributes objectForKey:_CPAttachmentView], viewSize = view ? view._frame.size : CGSizeMake(0, 0); rangeWidth = prevRangeWidth + viewSize.width; // undo sizing of dummy character isAttachment = YES; wrapRange = CPMakeRange(lineRange.location, lineRange.length - 1); // wrap before image // prevent crash when image is larger than text container if (viewSize.width > containerSizeWidth) wrapRange.length++; wrapWidth = rangeWidth; wrapRange._height = _lineHeight; wrapRange._base = _lineBase; if (viewSize.height > _lineBase) _lineBase = viewSize.height; if (viewSize.height > _lineHeight) _lineHeight = viewSize.height - descent + leading; // An attachment wider than the container stays on the line it overflows, so a break // there keeps the line's height and baseline with the attachment's size applied. // Otherwise the attachment would be placed on the baseline of the text before it, // and drawn above its line. if (CPMaxRange(wrapRange) === CPMaxRange(lineRange)) { wrapRange._height = _lineHeight; wrapRange._base = _lineBase; } ascent = viewSize.height; break; } case 9: // '\t' { // Measure against the actual text position before the tab stop var nextTab = [self textTabForWidth:prevRangeWidth + lineOrigin.x writingDirection:0]; isTabStop = YES; if (nextTab) { // Look-ahead to measure the width of the incoming text segment for alignment var nextSegmentWidth = 0.0, tempIndex = glyphIndex + 1, segmentString = ""; while (tempIndex < numberOfGlyphs) { var nextCharCode = theString.charCodeAt(tempIndex); if (nextCharCode === 9 || nextCharCode === 10 || nextCharCode === 13) break; segmentString += theString.charAt(tempIndex); tempIndex++; } if (segmentString.length > 0) nextSegmentWidth = [segmentString sizeWithFont:currentFont inWidth:NULL].width; var tabLocation = [nextTab location], tabAlignment = [nextTab alignment]; // Mathematically offset the tab character's right boundary if ([nextTab tabStopType] === CPDecimalTabStopType) { // As in Cocoa, the first decimal separator of the user's locale starts at the stop. // A segment without one ends at the stop, like a right tab. var separatorIndex = segmentString.indexOf(_CPTypesetterDecimalSeparator()), widthBeforeSeparator = separatorIndex === -1 ? nextSegmentWidth : [segmentString.substring(0, separatorIndex) sizeWithFont:currentFont inWidth:NULL].width; rangeWidth = (tabLocation - widthBeforeSeparator) - lineOrigin.x; } else if (tabAlignment === CPCenterTextAlignment) { rangeWidth = (tabLocation - nextSegmentWidth / 2.0) - lineOrigin.x; } else if (tabAlignment === CPRightTextAlignment) { rangeWidth = (tabLocation - nextSegmentWidth) - lineOrigin.x; } else // Left align tab stop { rangeWidth = tabLocation - lineOrigin.x; } // Enforce a minimum safety spacer width to avoid character overlapping var minRangeWidth = prevRangeWidth + 5.0; if (rangeWidth < minRangeWidth) rangeWidth = minRangeWidth; } else { rangeWidth = prevRangeWidth + 28.0; // standard fallback spacer } break; } case 32: // ' ' wrapRange = CPMakeRangeCopy(lineRange); wrapWidth = rangeWidth; wrapRange._height = _lineHeight; wrapRange._base = _lineBase; if (theString.charCodeAt(glyphIndex + 1) !== 32) { currentAnchor = rangeWidth; measuringRange = CPMakeRange(glyphIndex + 1, 0); } break; case 10: case 13: isNewline = YES; } advancements.push({width: rangeWidth - prevRangeWidth, height: ascent, descent: descent}); prevRangeWidth = _lineWidth = rangeWidth; // Wrap lines against the tail indent (rightMargin) instead of container boundaries if (lineOrigin.x + rangeWidth > rightMargin) { if (wrapWidth) { lineRange = wrapRange; _lineWidth = wrapWidth; _lineHeight = wrapRange._height; _lineBase = wrapRange._base; } isNewline = YES; isWordWrapped = YES; glyphIndex = CPMaxRange(lineRange) - 1; } if (isNewline || isTabStop || isAttachment) { if ([self _flushRange:lineRange lineOrigin:lineOrigin currentContainer:_currentTextContainer advancements:advancements lineCount:numLines sameLine:!isNewline wordWrapped:isWordWrapped rightMargin:rightMargin]) return; if (isTabStop || isAttachment) lineOrigin.x += rangeWidth; if (isNewline) { if (currentParagraphMinimumLineHeight && currentParagraphMinimumLineHeight > _lineHeight) _lineHeight = currentParagraphMinimumLineHeight; if (currentParagraphMaximumLineHeight && currentParagraphMaximumLineHeight < _lineHeight) _lineHeight = currentParagraphMaximumLineHeight; lineOrigin.y += _lineHeight; if (currentParagraphLineSpacing) lineOrigin.y += currentParagraphLineSpacing; if (lineOrigin.y > containerSizeHeight && _indexOfCurrentContainer < textContainersCount - 1) { _currentTextContainer = textContainers[++_indexOfCurrentContainer]; containerSize = [_currentTextContainer containerSize]; lineFragmentPadding = [_currentTextContainer lineFragmentPadding]; containerSizeWidth = containerSize.width - 2.0 * lineFragmentPadding; containerSizeHeight = containerSize.height; } isFirstLineOfParagraph = !isWordWrapped; lineOrigin.x = isFirstLineOfParagraph ? [_currentParagraph firstLineHeadIndent] : [_currentParagraph headIndent]; numLines++; isNewline = NO; _lineFragments = []; _lineHeight = 0; _lineBase = 0; isStartOfPhysicalLine = YES; } isTabStop = NO; isAttachment = NO; isWordWrapped = NO; _lineWidth = 0; advancements = []; currentAnchor = 0; prevRangeWidth = 0; lineRange = CPMakeRange(glyphIndex + 1, 0); measuringRange = CPMakeRange(glyphIndex + 1, 0); wrapRange = CPMakeRange(0, 0); wrapWidth = 0; } } // Flush remaining characters if (lineRange.length) [self _flushRange:lineRange lineOrigin:lineOrigin currentContainer:_currentTextContainer advancements:advancements lineCount:numLines sameLine:NO wordWrapped:NO rightMargin:rightMargin]; // The empty line after the text starts inside the padding, and at least 1 point in, so the insertion point stays visible. var rect = CGRectMake(MAX(1.0, lineFragmentPadding), lineOrigin.y - descent, containerSizeWidth, currentFontLineHeight); [_layoutManager setExtraLineFragmentRect:rect usedRect:rect textContainer:_currentTextContainer]; var fragment = [_layoutManager._lineFragments lastObject]; if (fragment) fragment._isLast = YES; } @end