NSAttributedString draw in rect

I need to draw an attributed string into a given rectangle. The string's height should be the same as the rectangle's height. This should work with any font a user chooses. To make it a bit more complicated, the string should also fill the rect if it consists only of uppercase characters or only of lowercase characters.

I am using NSLayoutManager to find the "best" font size for the selected font and the given recht and it works quite good with some fonts but with others it doesn't. Seems like the computed font size is always a bit too small and for some fonts it seems like the baseline must be corrected. Unfortunately I didn't find a way to calculate a baseline offset that really works with any font. I attached some sample images showing the issue.

Just posting this to make sure I am not running in the complete wrong direction. Any help would be highly appreciated. Thanks!

Answered by DTS Engineer in 800603022

but if I try to create a NSTextLineFragment from an attributed string,typographicBounds always returns {{0, 0}, {0, 0}}…

Yeah, that's because the text isn't laid out yet. To lay out the text, you need to set up the TextKit2 stack. The following code retrieves the size of an attributed string (NSAttributedString):

func sizeOf(nsAttributedString: NSAttributedString, width: CGFloat) -> CGSize {
    let textContainer = NSTextContainer(size: CGSize(width: width, height: 0))
    textContainer.lineFragmentPadding = 0
    
    let textLayoutManager = NSTextLayoutManager()
    textLayoutManager.textContainer = textContainer
    
    let textContentStorage = NSTextContentStorage()
    textContentStorage.attributedString = nsAttributedString
    textContentStorage.addTextLayoutManager(textLayoutManager)

    textLayoutManager.ensureLayout(for: textLayoutManager.documentRange)
    
    let rect = textLayoutManager.usageBoundsForTextContainer
    return rect.size
}

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

An idea: use boundingRect(with:options:context:) to test what is the rect for drawing and adjust size until it fits ?

Could you also post the code so that we can make some test.

@Claude31 This is more or less what I am actually doing but the returned bounding rect seems to have some sort of spacing around the text.

This is how I try to get the font size at the moment but it seems the value is always a bit too small…

- (CGFloat)fontSizeToFitInRect:(NSRect)rect
               minimumFontSize:(CGFloat)minFontSize
               maximumFontSize:(CGFloat)maxFontSize
{
    CGFloat fontSize = (maxFontSize > minFontSize) ? maxFontSize : NSHeight(rect);
    CGFloat textHeight = CGFLOAT_MAX;
    CGFloat textWidth = CGFLOAT_MAX;
            
    while ((textHeight > NSHeight(rect) || textWidth > NSWidth(rect)) && fontSize >= minFontSize) {

        NSMutableAttributedString *attrString = [[NSMutableAttributedString alloc] initWithAttributedString:self];
        [attrString addAttribute:NSFontAttributeName
                           value:[[NSFontManager sharedFontManager] convertFont:[self font] toSize:--fontSize]
                           range:NSMakeRange(0, [[self string] length])
        ];
        
        CGRect usedRect = [attrString boundingRectWithSize:NSMakeSize(CGFLOAT_MAX, CGFLOAT_MAX)
                                                   options:0
                                                   context:nil
        ];
        
        textHeight = ceil(NSHeight(usedRect));
        textWidth = ceil(NSWidth(usedRect));
    }
    
    return fontSize;
}

For me it looks like boundingRectWithSize:options:context: just uses the attributed string's font (and its attributes) to calculate a bounding rectangle that could hold any combination of characters with the specific font and font size. The height of the bounding rect is more or less the same, regardless of what combinations of characters I use in the string. So the question is how to get the real bounding rect… 🤷🏻‍♂️

boundingRect(with:options:context:) returns an estimated rectangle based on the font used in the attributed string, which can be slightly different from the rectangle the text actually uses after it is laid out. That is partly because the layout process handles kern and ligature more accurately.

Did you try with TextKit2 yet? If not, I'd suggest that you give it a try because:

  1. The Text technology has been evolving to TextKit2 for years. For a new development, adopting TextKit2 makes your development better future-proof.

  2. With TextKit2, typographicBounds should give you an accurate bounds.

To verify #2, you can start with putting your text into the following sample, and then check if the typographic rectangle is accurate. The sample has a button that draws the typographic rectangle of a text fragment (with purple color):

Please share your finding, if you don't mind. I'd see if there is anything to follow up.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

@DTS Engineer

Thank you! This looks very promising. In the sample code it works as expected but if I try to create a NSTextLineFragment from an attributed string,typographicBounds always returns {{0, 0}, {0, 0}}

 NSMutableAttributedString *attrString = [[NSMutableAttributedString alloc] initWithString:@"test"];
    [attrString addAttribute:NSFontAttributeName
                       value:[NSFont systemFontOfSize:90]
                       range:NSMakeRange(0, [[attrString string] length])
    ];
    
    NSTextLineFragment *fragment = [[NSTextLineFragment alloc] initWithAttributedString:attrString
                                                                                  range:NSMakeRange(0, [[attrString string] length])
    ];
    CGRect usedRect = [fragment typographicBounds];

but if I try to create a NSTextLineFragment from an attributed string,typographicBounds always returns {{0, 0}, {0, 0}}…

Yeah, that's because the text isn't laid out yet. To lay out the text, you need to set up the TextKit2 stack. The following code retrieves the size of an attributed string (NSAttributedString):

func sizeOf(nsAttributedString: NSAttributedString, width: CGFloat) -> CGSize {
    let textContainer = NSTextContainer(size: CGSize(width: width, height: 0))
    textContainer.lineFragmentPadding = 0
    
    let textLayoutManager = NSTextLayoutManager()
    textLayoutManager.textContainer = textContainer
    
    let textContentStorage = NSTextContentStorage()
    textContentStorage.attributedString = nsAttributedString
    textContentStorage.addTextLayoutManager(textLayoutManager)

    textLayoutManager.ensureLayout(for: textLayoutManager.documentRange)
    
    let rect = textLayoutManager.usageBoundsForTextContainer
    return rect.size
}

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

@DTS Engineer

Thanks, again. I tried your code but it returns more or less the same rect as boundingRectWithSize:options:context:. So it returns the exact same height for a lower case "x" and an upper case "X":

x -> {{-66, 0}, {132, 303.63671875}}
X -> {{-82.88671875, 0}, {165.7734375, 303.63671875}}

Any idea? Thanks…

Would you mind to share the input string you used? The x and y don't seem to be an output of my code.

To be clear, the code example I provided above is meant to return the size of a block of text laid out with TextKit2, which is indeed similar to boundingRect(with:options:context:), but is improved in some cases base on my testing.

For a single character, the code pretty much returns the point size of the font (UIFont.pointSize), and that is probably why you see the same height for x and X.

If you really need to be accurate down to the glyph level, you might consider using Core Text. The following code uses Core Text to print the line size based on glyph size. You can give it a try and let me know if that helps your use case.

    func printFirstLineBoundingRect() {
        let string = "x"
        let attributes: [NSAttributedString.Key: Any] = [
            .font: UIFont.systemFont(ofSize: 20) as Any
        ]
        let attributedString = NSAttributedString(string: string, attributes: attributes) as CFAttributedString
        let framesetter = CTFramesetterCreateWithAttributedString(attributedString)
        
        let rect = CGRect(x: 0, y: 0, width: 200, height: 200)
        let ctFrame = CTFramesetterCreateFrame(framesetter, CFRange(), CGPath(rect: rect, transform: nil), nil)
        let lines = CTFrameGetLines(ctFrame) as? [CTLine] ?? []
        let lineRect = boundingRectOf(line: lines.first!)
        print("$$$ First line bounding rect: \(lineRect)")
    }


    func boundingRectOf(line: CTLine) -> CGRect {
        var result = CGRect.zero
        if let ctRuns = CTLineGetGlyphRuns(line) as? [CTRun]  {
            for ctRun in ctRuns {
                let attributes = CTRunGetAttributes(ctRun) as NSDictionary as! [String: Any]
                guard let font = attributes[kCTFontAttributeName as String] else {
                    break
                }
                
                let runGlyphsCount = CTRunGetGlyphCount(ctRun)
                let glyphs = [CGGlyph](unsafeUninitializedCapacity: runGlyphsCount) { (bufferPointer, count) in
                    CTRunGetGlyphs(ctRun, CFRange(), bufferPointer.baseAddress!)
                    count = runGlyphsCount
                }
                
                let runRects = [CGRect](unsafeUninitializedCapacity: runGlyphsCount) { (bufferPointer, count) in
                    CTFontGetBoundingRectsForGlyphs(font as! CTFont, CTFontOrientation.default, glyphs, bufferPointer.baseAddress!, runGlyphsCount)
                    count = runGlyphsCount
                }
                
                result = runRects.reduce(result) { (partialResult, rect) in
                    let width = partialResult.width + rect.width
                    let height = max(partialResult.height, rect.height)
                    let y = min(partialResult.origin.y, rect.origin.y)
                    return CGRect(x: partialResult.origin.x, y: y, width: width, height: height)
                }
            }
        }
        return result
    }

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

@DTS Engineer

Thanks a lot. Seems this is what I was looking for. This allows me to figure out the best font size for a text to fit in a given rect. The only issue still see is related to drawing the string. For whatever reason half of the string is missing:

Here's the code:

- (void)drawRect:(NSRect)dirtyRect
{
    [super drawRect:dirtyRect];
    
    NSDictionary *attributes = [NSDictionary dictionaryWithObject:[NSFont systemFontOfSize:10] forKey:NSFontAttributeName];
    NSMutableAttributedString *attrString = [[NSMutableAttributedString alloc] initWithString:@"test" attributes:attributes];
    
    // get the font size to make the string fit into the text view
    CGFloat fontSize = [attrString fontSizeToFitInRect:[self bounds]
                                       minimumFontSize:10
                                       maximumFontSize:0
    ];
    
    // change the attributed string's font size
    [attrString addAttribute:NSFontAttributeName
                       value:[[NSFontManager sharedFontManager] convertFont:[attrString font] toSize:fontSize]
                       range:NSMakeRange(0, [attrString length])
    ];
    
    // draw the string
    [attrString drawInRect:[self frame]];
}

And here are the extensions I made to the NSAttributedString class to calculate the string's bounds:

- (CGFloat)fontSizeToFitInRect:(NSRect)rect
               minimumFontSize:(CGFloat)minFontSize
               maximumFontSize:(CGFloat)maxFontSize
{
    CGFloat fontSize = (maxFontSize > minFontSize) ? maxFontSize : NSHeight(rect) * 2;
    CGFloat textHeight = CGFLOAT_MAX;
    CGFloat textWidth = CGFLOAT_MAX;
            
    while ((textHeight > NSHeight(rect) || textWidth > NSWidth(rect)) && fontSize >= minFontSize) {

        NSMutableAttributedString *attrString = [[NSMutableAttributedString alloc] initWithAttributedString:self];
        [attrString addAttribute:NSFontAttributeName
                           value:[[NSFontManager sharedFontManager] convertFont:[self font] toSize:--fontSize]
                           range:NSMakeRange(0, [[self string] length])
        ];

        CGRect usedRect = [attrString bounds];

        textHeight = ceil(NSHeight(usedRect));
        textWidth = ceil(NSWidth(usedRect));
    }
    
    return fontSize;
}

- (CGRect)bounds
{
    CGRect usedRect = CGRectZero;
    
    CTLineRef line = CTLineCreateWithAttributedString((CFAttributedStringRef)self);
    NSArray *ctRuns = (__bridge NSArray*)CTLineGetGlyphRuns(line);
        
    for (id ctRun in ctRuns) {
        
        NSDictionary *attributes = (__bridge NSDictionary *)CTRunGetAttributes((CTRunRef)ctRun);
        id font = attributes[(NSString *)kCTFontAttributeName];
        
        if (font) {
            
            size_t runGlyphsCount = CTRunGetGlyphCount((CTRunRef)ctRun);
            CGGlyph glyphs[runGlyphsCount];
            CTRunGetGlyphs((CTRunRef)ctRun, CFRangeMake(0, 0), glyphs);
            
            CGRect runRects[runGlyphsCount];
            CTFontGetBoundingRectsForGlyphs((CTFontRef)font, kCTFontOrientationDefault, glyphs, runRects, runGlyphsCount);
            
            for (size_t i = 0; i < runGlyphsCount; i++) {
                
                CGRect rect = runRects[i];
                CGFloat width = usedRect.size.width + rect.size.width;
                CGFloat height = MAX(usedRect.size.height, rect.size.height);
                CGFloat y = MIN(usedRect.origin.y, rect.origin.y);
                usedRect = CGRectMake(usedRect.origin.x, y, width, height);
            }
            
        } else {
            break;
        }
    }
    
    return usedRect;
}

The string rect is a bit smaller than the rectangle of the view it should be drawn into. So I wonder why the string is cut off. With upper and lower case characters it looks like this:

Any idea? Thank you very much.

Regards, Marc

Ok. I think I also have to use Core Text for drawing the string. Right? Would be something like CGContextShowGlyphsAtPositions a good way to go?

This is what I have so far:

CTLineRef line = CTLineCreateWithAttributedString((CFAttributedStringRef)self);
    
if (line) {
        
        CGContextRef context = [[NSGraphicsContext currentContext] CGContext];
        CTLineDraw(line, context);
        CFRelease(line);
}

Looks quite good…

Just have to figure out how to align it horizontally and vertically in the rect.

Accepted Answer

Ok, I figured it out. The following code gives me the correct bounds of the attributed string:

- (CGRect)typographicBounds
{
    CGRect usedRect = CGRectZero;
    
    CTLineRef line = CTLineCreateWithAttributedString((CFAttributedStringRef)self);
    
    if (line) {
        
        CGContextRef context = [[NSGraphicsContext currentContext] CGContext];
        usedRect = CTLineGetImageBounds(line, context);
        CFRelease(line);
    }
    
    return usedRect;
}

and the following code draws it exactly into the view:

NSRect stringRect = [_attributedString typographicBounds];

        // draw the string
        CTLineRef line = CTLineCreateWithAttributedString((CFAttributedStringRef)_attributedString);
        
        if (line) {
            
            CGContextRef context = [[NSGraphicsContext currentContext] CGContext];
            CGContextSetTextPosition(
                                     context,
                                     ((NSWidth([self frame]) - NSWidth(stringRect)) / 2) - stringRect.origin.x,
                                     ((NSHeight([self frame]) - NSHeight(stringRect)) / 2) - stringRect.origin.y
                                     );
            CTLineDraw(line, context);
            CFRelease(line);
        }

@DTS Engineer Thank you for your patience and for showing me the right direction.

NSAttributedString draw in rect
 
 
Q