Core Text's CTFramesetterSuggestFrameSizeWithConstraints() returns incorrect size every time
Asked Answered
V

7

11

According to the docs, CTFramesetterSuggestFrameSizeWithConstraints () "determines the frame size needed for a string range".

Unfortunately the size returned by this function is never accurate. Here is what I am doing:

    NSAttributedString *string = [[[NSAttributedString alloc] initWithString:@"lorem ipsum" attributes:nil] autorelease];
    CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef) string);
    CGSize textSize = CTFramesetterSuggestFrameSizeWithConstraints(framesetter, CFRangeMake(0,0), NULL, CGSizeMake(rect.size.width, CGFLOAT_MAX), NULL);

The returned size always has the correct width calculated, however the height is always slightly shorter than what is expected.

Is this the correct way to use this method?

Is there any other way to layout Core Text?

Seems I am not the only one to run into problems with this method. See https://devforums.apple.com/message/181450.

Edit: I measured the same string with Quartz using sizeWithFont:, supplying the same font to both the attributed string, and to Quartz. Here are the measurements I received:

Core Text: 133.569336 x 16.592285

Quartz: 135.000000 x 31.000000

Vasyuta answered 25/4, 2010 at 9:2 Comment(2)
got same problem. always calculates one line less than it should if i ask it for calculating 3 lines it will give me the correct calculation for 2.. etc etc.Janeth
See this related question: #3375091Campestral
L
15

try this.. seem to work:

+(CGFloat)heightForAttributedString:(NSAttributedString *)attrString forWidth:(CGFloat)inWidth
{
    CGFloat H = 0;

    // Create the framesetter with the attributed string.
    CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString( (CFMutableAttributedStringRef) attrString); 

    CGRect box = CGRectMake(0,0, inWidth, CGFLOAT_MAX);

    CFIndex startIndex = 0;

    CGMutablePathRef path = CGPathCreateMutable();
    CGPathAddRect(path, NULL, box);

    // Create a frame for this column and draw it.
    CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(startIndex, 0), path, NULL);

    // Start the next frame at the first character not visible in this frame.
    //CFRange frameRange = CTFrameGetVisibleStringRange(frame);
    //startIndex += frameRange.length;

    CFArrayRef lineArray = CTFrameGetLines(frame);
    CFIndex j = 0, lineCount = CFArrayGetCount(lineArray);
    CGFloat h, ascent, descent, leading;

    for (j=0; j < lineCount; j++)
    {
        CTLineRef currentLine = (CTLineRef)CFArrayGetValueAtIndex(lineArray, j);
        CTLineGetTypographicBounds(currentLine, &ascent, &descent, &leading);
        h = ascent + descent + leading;
        NSLog(@"%f", h);
        H+=h;
    }

    CFRelease(frame);
    CFRelease(path);
    CFRelease(framesetter);


    return H;
}
Lubalubba answered 18/11, 2010 at 12:58 Comment(3)
This seems to be the only realiable solution I can imagine. All other ways fails for some cases.Irresistible
If you use zero as height, it does not restrict the height to any height. I wrote a category for this issue, which works for me in all cases. See #16586690Slemmer
If anyone is interested, this has essentially the same performance as CTFramesetterSuggestFrameSizeWithConstraints except it's actually correct everytimeFirefly
W
5

For a single line frame, try this:

line = CTLineCreateWithAttributedString((CFAttributedStringRef) string);
CGFloat ascent;
CGFloat descent;
CGFloat width = CTLineGetTypographicBounds(line, &ascent, &descent, NULL);
CGFloat height = ascent+descent;
CGSize textSize = CGSizeMake(width,height);

For multiline frames, you also need to add the line's lead (see a sample code in Core Text Programming Guide)

For some reason, CTFramesetterSuggestFrameSizeWithConstraints() is using the difference in ascent and descent to calculate the height:

CGFloat wrongHeight = ascent-descent;
CGSize textSize = CGSizeMake(width, wrongHeight);

It could be a bug?

I'm having some other problems with the width of the frame; It's worth checking out as it only shows in special cases. See this question for more.

Wadesworth answered 5/5, 2010 at 2:45 Comment(2)
Many thanks, Mo. This helped me find what appears to be a reliable workaround for the SuggestSize when doing multiline labels.Safar
Glad I could help. Did you check if I was right about the wrongHeight? I want to file a bug report. PS. thanks gf.Wadesworth
L
3

The problem is that you have to apply a paragraph style to the text before you measure it. If you don't then you get the default leading of 0.0. I provided a code sample for how to do this in my answer to a duplicate of this question here https://mcmap.net/q/23698/-ctframesettersuggestframesizewithconstraints-sometimes-returns-incorrect-size.

Lazuli answered 4/4, 2012 at 21:21 Comment(0)
F
1

ing.conti's answer but in Swift 4:

    var H:CGFloat = 0

    // Create the framesetter with the attributed string.
    let framesetter = CTFramesetterCreateWithAttributedString(attributedString as! CFMutableAttributedString)
    let box:CGRect = CGRect.init(x: 0, y: 0, width: width, height: CGFloat.greatestFiniteMagnitude)

    let startIndex:CFIndex = 0

    let path:CGMutablePath = CGMutablePath()
    path.addRect(box)

    // Create a frame for this column and draw it.
    let frame:CTFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(startIndex, 0), path, nil)
    // Start the next frame at the first character not visible in this frame.
    //CFRange frameRange = CTFrameGetVisibleStringRange(frame);
    //startIndex += frameRange.length;

    let lineArray:CFArray = CTFrameGetLines(frame)
    let lineCount:CFIndex = CFArrayGetCount(lineArray)
    var h:CGFloat = 0
    var ascent:CGFloat = 0
    var descent:CGFloat = 0
    var leading:CGFloat = 0

    for j in 0..<lineCount {
        let currentLine = unsafeBitCast(CFArrayGetValueAtIndex(lineArray, j), to: CTLine.self)
        CTLineGetTypographicBounds(currentLine, &ascent, &descent, &leading)
        h = ascent + descent + leading;
        H+=h;
    }
    return H;

I did try and keep it as 1:1 with the Objective C code but Swift is not as nice when handling pointers so some changes were required for casting.

I also did some benchmarks comparing this code (and it's ObjC counterpart) to another height methods. As a heads up, I used a HUGE and very complex attributed string as input and also did it on the sim so the times themselves are meaningless however the relative speeds are correct.

Runtime for 1000 iterations (ms) BoundsForRect: 8909.763097763062
Runtime for 1000 iterations (ms) layoutManager: 7727.7010679244995
Runtime for 1000 iterations (ms) CTFramesetterSuggestFrameSizeWithConstraints: 1968.9229726791382
Runtime for 1000 iterations (ms) CTFramesetterCreateFrame ObjC: 1941.6030206680298
Runtime for 1000 iterations (ms) CTFramesetterCreateFrame-Swift: 1912.694974899292
Firefly answered 21/8, 2018 at 18:18 Comment(0)
C
0

It might seem strange but I found that if you use ceil function first and then add +1 to the height it will always work. Many third party APIs use this trick.

Chilon answered 4/9, 2011 at 8:4 Comment(0)
A
0

Resurrecting.

When initially determining where lines should be placed within a frame, Core Text seems to massage the ascent+descent for the purposes of line origin calculation. In particular, it seems like 0.2*(ascent+descent) is added to the ascent, and then both the descent and resultant ascent are modified by floor(x + 0.5), and then the baseline positions are calculated based on these adjusted ascents and descents. Both of these steps are affected by certain conditions whose nature I am not sure, and I also already forgot at which point paragraph styles are taken into account, despite only looking into it a few days ago.

I've already resigned to just considering a line to start at its baseline and not trying to figure out what the actual lines land at. Unfortunately, this still does not seem to be enough: paragraph styles are not reflected in CTLineGetTypographicBounds(), and some fonts like Klee that have nonzero leadings wind up crossing the path rect! Not sure what to do about this... probably for another question.

UPDATE

It seems CTLineGetBoundsWithOptions(line, 0) does get the proper line bounds, but not quite fully: there's a gap between lines, and with some fonts (Klee again) the gap is negative and the lines overlap... Not sure what to do about this. :| At least we're slightly closer??

And even then it still does not take paragraph styles into consideration >:|

CTLineGetBoundsWithOptions() is not listed on Apple's documentation site, possibly due to a bug in the current version of their documentation generator. It is a fully documented API, however — you'll find it in the header files and it was discussed at length at WWDC 2012 session 226.

None of the options are relevant to us: they reduce the bounds rect by taking certain font design choices into consideration (or increase the bounds rect randomly, in the case of the new kCTLineBoundsIncludeLanguageExtents). One useful option in general, though, is kCTLineBoundsUseGlyphPathBounds, which is equivalent to CTLineGetImageBounds() but without needing to specify a CGContext (and thus without being subject to an existing text matrix or CTM).

Alleris answered 7/1, 2017 at 18:17 Comment(0)
C
0

After weeks of trying everything, any combination possible, I made a break through and found something that works. This issue seems to be more prominent on macOS than on iOS, but still appears on both.

What worked for me was to use a CATextLayer instead of a NSTextField (on macOS) or a UILabel (on iOS).

And using boundingRect(with:options:context:) instead of CTFramesetterSuggestFrameSizeWithConstraints. Even though in theory the latter should be more lower level than the former, and I was assuming would be more precise, the game changer turns out to be NSString.DrawingOptions.usesDeviceMetrics.

The frame size suggested fits like a charm.

Example:

let attributedString = NSAttributedString(string: "my string")
let maxWidth = CGFloat(300)
let size = attributedString.boundingRect(
                with: .init(width: maxWidth,
                            height: .greatestFiniteMagnitude),
                options: [
                    .usesFontLeading,
                    .usesLineFragmentOrigin,
                    .usesDeviceMetrics])

let textLayer = CATextLayer()
textLayer.frame = .init(origin: .zero, size: size)
textLayer.contentsScale = 2 // for retina
textLayer.isWrapped = true // for multiple lines
textLayer.string = attributedString

Then you can add the CATextLayer to any NSView/UIView.

macOS

let view = NSView()
view.wantsLayer = true
view.layer?.addSublayer(textLayer)

iOS

let view = UIView()
view.layer.addSublayer(textLayer)
Calcify answered 10/7, 2022 at 12:49 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.