Core Text calculate letter frame in iOS
Asked Answered
I

2

59

I need to calculate exact bounding box for every each character (glyph) in NSAttributedString (Core Text). After putting together some code used to solve similar problems (Core Text selection, etc..), the result is quite good, but only few frames (red) are being calculated properly:

enter image description here

Most of the frames are misplaces either horizontally or vertically (by tiny bit). What is the cause of that? How can I perfect this code?:

-(void)recalculate{

    // get characters from NSString
    NSUInteger len = [_attributedString.string length];
    UniChar *characters = (UniChar *)malloc(sizeof(UniChar)*len);
    CFStringGetCharacters((__bridge CFStringRef)_attributedString.string, CFRangeMake(0, [_attributedString.string length]), characters);

    // allocate glyphs and bounding box arrays for holding the result
    // assuming that each character is only one glyph, which is wrong
    CGGlyph *glyphs = (CGGlyph *)malloc(sizeof(CGGlyph)*len);
    CTFontGetGlyphsForCharacters(_font, characters, glyphs, len);

    // get bounding boxes for glyphs
    CTFontGetBoundingRectsForGlyphs(_font, kCTFontDefaultOrientation, glyphs, _characterFrames, len);
    free(characters); free(glyphs);

    // Measure how mush specec will be needed for this attributed string
    // So we can find minimun frame needed
    CFRange fitRange;
    CGSize s = CTFramesetterSuggestFrameSizeWithConstraints(_framesetter, rangeAll, NULL, CGSizeMake(W, MAXFLOAT), &fitRange);

    _frameRect = CGRectMake(0, 0, s.width, s.height);
    CGPathRef framePath = CGPathCreateWithRect(_frameRect, NULL);
    _ctFrame = CTFramesetterCreateFrame(_framesetter, rangeAll, framePath, NULL);
    CGPathRelease(framePath);


    // Get the lines in our frame
    NSArray* lines = (NSArray*)CTFrameGetLines(_ctFrame);
    _lineCount = [lines count];

    // Allocate memory to hold line frames information:
    if (_lineOrigins != NULL)free(_lineOrigins);
    _lineOrigins = malloc(sizeof(CGPoint) * _lineCount);

    if (_lineFrames != NULL)free(_lineFrames);
    _lineFrames = malloc(sizeof(CGRect) * _lineCount);

    // Get the origin point of each of the lines
    CTFrameGetLineOrigins(_ctFrame, CFRangeMake(0, 0), _lineOrigins);

    // Solution borrowew from (but simplified):
    // https://github.com/twitter/twui/blob/master/lib/Support/CoreText%2BAdditions.m


    // Loop throught the lines
    for(CFIndex i = 0; i < _lineCount; ++i) {

        CTLineRef line = (__bridge CTLineRef)[lines objectAtIndex:i];

        CFRange lineRange = CTLineGetStringRange(line);
        CFIndex lineStartIndex = lineRange.location;
        CFIndex lineEndIndex = lineStartIndex + lineRange.length;

        CGPoint lineOrigin = _lineOrigins[i];
        CGFloat ascent, descent, leading;
        CGFloat lineWidth = CTLineGetTypographicBounds(line, &ascent, &descent, &leading);


        // If we have more than 1 line, we want to find the real height of the line by measuring the distance between the current line and previous line. If it's only 1 line, then we'll guess the line's height.
        BOOL useRealHeight = i < _lineCount - 1;
        CGFloat neighborLineY = i > 0 ? _lineOrigins[i - 1].y : (_lineCount - 1 > i ? _lineOrigins[i + 1].y : 0.0f);
        CGFloat lineHeight = ceil(useRealHeight ? abs(neighborLineY - lineOrigin.y) : ascent + descent + leading);

        _lineFrames[i].origin = lineOrigin;
        _lineFrames[i].size = CGSizeMake(lineWidth, lineHeight);

        for (int ic = lineStartIndex; ic < lineEndIndex; ic++) {

            CGFloat startOffset = CTLineGetOffsetForStringIndex(line, ic, NULL);
            _characterFrames[ic].origin = CGPointMake(startOffset, lineOrigin.y);
        }
    }
}


#pragma mark - Rendering Text:

-(void)renderInContext:(CGContextRef)context contextSize:(CGSize)size{

    CGContextSaveGState(context);

    // Draw Core Text attributes string:
    CGContextSetTextMatrix(context, CGAffineTransformIdentity);
    CGContextTranslateCTM(context, 0, CGRectGetHeight(_frameRect));
    CGContextScaleCTM(context, 1.0, -1.0);

    CTFrameDraw(_ctFrame, context);

    // Draw line and letter frames:
    CGContextSetStrokeColorWithColor(context, [UIColor colorWithRed:0.0 green:0.0 blue:1.0 alpha:0.5].CGColor);
    CGContextSetLineWidth(context, 1.0);

    CGContextBeginPath(context);
    CGContextAddRects(context, _lineFrames, _lineCount);
    CGContextClosePath(context);
    CGContextStrokePath(context);

    CGContextSetStrokeColorWithColor(context, [UIColor colorWithRed:1.0 green:0.0 blue:0.0 alpha:0.5].CGColor);
    CGContextBeginPath(context);
    CGContextAddRects(context, _characterFrames, _attributedString.string.length);
    CGContextClosePath(context);
    CGContextStrokePath(context);

    CGContextRestoreGState(context);

}
Impeditive answered 29/1, 2014 at 22:3 Comment(6)
Do you need to work at that level? It seems like NSLayoutManager's -boundingRectForGlyphRange:inTextContainer: method might do the trick if you call it for each glyph and give it a range of length 1.Azikiwe
NSLayoutManager is available from iOS7. I want to support iOS6 at least.Impeditive
Gotcha, although that becomes less important with each passing day.Azikiwe
Still, it does not mean one should stop using and trying ot understand underlaying Core Text...Impeditive
I actually haven't bee able to reproduce @Impeditive results using boundingRectForGlyphRange:inTextContainer. See #21764816Myocarditis
Was kCTFontDefaultOrientation changed to kCTFontOrientationDefault?Touchy
C
65

You did an impressive amount of work in your question and were so close on your own. The problem you were having comes from this line of code where you position the bounding boxes for each frame:

_characterFrames[ic].origin = CGPointMake(startOffset, lineOrigin.y);

The problem with it is that you are overriding whatever offset the frame already had.

If you were to comment out that line you would see that all the frames were positioned more or less in the same place but you would also see that they are not positioned at the exact same place. Some are positioned more to the left or right and some more up or down. This means that the frames for the glyphs have a position of their own.

enter image description here

The solution to your problem is to take the current position of the frames into account when you move them into their correct place on the lines. You can either do it by adding to x and y separately:

_characterFrames[ic].origin.x += startOffset;
_characterFrames[ic].origin.y += lineOrigin.y;

or by offsetting the rectangle:

_characterFrames[ic] = CGRectOffset(_characterFrames[ic],
                                    startOffset, lineOrigin.y);

Now the bounding boxes will have their correct positions:

enter image description here

and you should see that it works for some of the more extreme fonts out there

enter image description here

Caucasian answered 1/2, 2014 at 11:53 Comment(7)
Zapfino always has a melting effect in my heart! <3Tetrabranchiate
@LeoNatan It's also the "did I really get it right?" of most things text related :)Emissive
@DavidRönnqvist The LinoType Zapfino Extra LT (and Extra LTX) are much more interesting, for their complex scripting of ligatures. It would be interesting running that code on a heavily contextualized Zapfino Extra LTX sample. :DTetrabranchiate
How can I do the same for other glyphs then letters or numbers? This technique does not work for example for @.#),-+ etc. as CTFontGetBoundingRectsForGlyphs returns exactly following rectangle {x: 0.6, y: 0, w: 4.8, h: 6.144} for anything else then numbers and letters - no matter what the real dimension is.Bethezel
@drasto My code from before works just fine with @.#),-+. However, it breaks for composed characters (😀⌘ etc.)Emissive
@drasto But that would depend on the font you are using. Every character that isn't supported for that font would be returned as 0 from CTFontGetGlyphsForCharacters and passing that into CTFontGetBoundingRectsForGlyphs does (as you've seen) return the bounds for the wrong glyph.Emissive
I am using HelveticaNeue - all used characters should be supported. Definitelly in Swift CTFontGetBoundingRectsForGlyphs returns constant (thus incorrect) rectangle for everything but numbers and letters. Can you have a look on this question? There is the exact code I am usingBethezel
H
1

Swift version

Swift 5, Xcode 11:

 override func draw(_ rect: CGRect) {
        guard let context = UIGraphicsGetCurrentContext() else { return }
   
    
       context.textMatrix = .identity
       context.translateBy(x: 0, y: self.bounds.size.height)
       context.scaleBy(x: 1.0, y: -1.0)
   
    
       let string = "|優勝《ゆうしょう》の|懸《か》かった|試合《しあい》。|Test《テスト》.\nThe quick brown fox jumps over the lazy dog. 12354567890 @#-+"

    
       let attributedString = Utility.sharedInstance.furigana(String: string)
   
       let range = attributedString.mutableString.range(of: attributedString.string)

       attributedString.addAttribute(.font, value: font, range: range)

   
       let framesetter = attributedString.framesetter()
   
       let textBounds = self.bounds.insetBy(dx: 20, dy: 20)
       let frame = framesetter.createFrame(textBounds)
    
//Draw the frame text:
   
       frame.draw(in: context)
           
       let origins = frame.lineOrigins()
   
       let lines = frame.lines()

        context.setStrokeColor(UIColor.red.cgColor)
        context.setLineWidth(0.7)

        for i in 0 ..< origins.count {

            let line = lines[i]
         
            for run in line.glyphRuns() {
            
                let font = run.font
                let glyphPositions = run.glyphPositions()
                let glyphs = run.glyphs()
            
                let glyphsBoundingRects =  font.boundingRects(of: glyphs)
            
//DRAW the bounding box for each glyph:
            
                for k in 0 ..< glyphPositions.count {
                    let point = glyphPositions[k]
                    let gRect = glyphsBoundingRects [k]
            
                    var box = gRect
                    box.origin +=  point + origins[i] + textBounds.origin
                    context.stroke(box)
                        
                }// for k
        
            }//for run
        
       }//for i
    
    }//func draw

Made with a CoreText Swift Wrapper. Full Source: https://github.com/huse360/LetterFrame

Homeopathist answered 13/8, 2020 at 1:44 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.