CTFramesetterSuggestFrameSizeWithConstraints sometimes returns incorrect size?
Asked Answered
S

7

15

In the code below, CTFramesetterSuggestFrameSizeWithConstraints sometimes returns a CGSize with a height that is not big enough to contain all the text that is being passed into it. I did look at this answer. But in my case the width of the text box needs to be constant. Is there any other/better way to figure out the correct height for my attributed string? Thanks!

CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString(attributedString);
CGSize tmpSize = CTFramesetterSuggestFrameSizeWithConstraints(framesetter, CFRangeMake(0,0), NULL, CGSizeMake(self.view.bounds.size.width, CGFLOAT_MAX), NULL); 
CGSize textBoxSize = CGSizeMake((int)tmpSize.width + 1, (int)tmpSize.height + 1);
Starlastarlene answered 30/7, 2010 at 19:5 Comment(0)
D
62

CTFramesetterSuggestFrameSizeWithConstraints works correctly. The reason that you get a height that is too short is because of the leading in the default paragraph style attached to attributed strings. If you don't attach a paragraph style to the string then CoreText returns the height needed to render the text, but with no space between the lines. This took me forever to figure out. Nothing in the documentation spells it out. I just happened to notice that my heights were short by an amount equal to (number of lines x expected leading). To get the height result you expect you can use code like the following:

NSString  *text = @"This\nis\nsome\nmulti-line\nsample\ntext."
UIFont    *uiFont = [UIFont fontWithName:@"Helvetica" size:17.0];
CTFontRef ctFont = CTFontCreateWithName((CFStringRef) uiFont.fontName, uiFont.pointSize, NULL);

//  When you create an attributed string the default paragraph style has a leading 
//  of 0.0. Create a paragraph style that will set the line adjustment equal to
//  the leading value of the font.
CGFloat leading = uiFont.lineHeight - uiFont.ascender + uiFont.descender;
CTParagraphStyleSetting paragraphSettings[1] = { kCTParagraphStyleSpecifierLineSpacingAdjustment, sizeof (CGFloat), &leading };

CTParagraphStyleRef  paragraphStyle = CTParagraphStyleCreate(paragraphSettings, 1);
CFRange textRange = CFRangeMake(0, text.length);

//  Create an empty mutable string big enough to hold our test
CFMutableAttributedStringRef string = CFAttributedStringCreateMutable(kCFAllocatorDefault, text.length);

//  Inject our text into it
CFAttributedStringReplaceString(string, CFRangeMake(0, 0), (CFStringRef) text);

//  Apply our font and line spacing attributes over the span
CFAttributedStringSetAttribute(string, textRange, kCTFontAttributeName, ctFont);
CFAttributedStringSetAttribute(string, textRange, kCTParagraphStyleAttributeName, paragraphStyle);

CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString(string);
CFRange fitRange;

CGSize frameSize = CTFramesetterSuggestFrameSizeWithConstraints(framesetter, textRange, NULL, bounds, &fitRange);

CFRelease(framesetter);
CFRelease(string);
Dagnydago answered 4/4, 2012 at 21:16 Comment(5)
so how is this done when you have multiple fonts in the attributed string?Aeromechanics
You can add the paragraph attribute to the string by using CFAttributedStringSetAttributes and pass NO for clearOtherAttributesAdolfo
It's still coming out too short for me, even using Chris's code.Laconic
Thanks for the excellent info. You should release "paragraphStyle".Rivulet
Sometimes this approach fail for me, the height is to short.Impossibility
K
20

CTFramesetterSuggestFrameSizeWithConstraints() is broken. I filed a bug on this a while back. Your alternative is to use CTFramesetterCreateFrame() with a path that is sufficiently high. Then you can measure the rect of the CTFrame that you get back. Note that you cannot use CGFLOAT_MAX for the height, as CoreText uses a flipped coordinate system from the iPhone and will locate its text at the "top" of the box. This means that if you use CGFLOAT_MAX, you won't have enough precision to actually tell the height of the box. I recommend using something like 10,000 as your height, as that's 10x taller than the screen itself and yet gives enough precision for the resulting rectangle. If you need to lay out even taller text, you can do this multiple times for each section of text (you can ask CTFrameRef for the range in the original string that it was able to lay out).

Kingsize answered 31/7, 2010 at 5:43 Comment(6)
CGFLOAT_MAX is really a problem in the current case, setting the value to 100.000 seems to work great. Still it's strange that I face this problem only on iOS5.Menispermaceous
@GregoryM: Yeah, that's why I suggested using something like 10,000, which is the value I used (large enough to be useful, small enough to still allow plenty of precision in the fractional part).Kingsize
this is an old answer but how do you get the CGRect or height from a CTFrame?Pretender
@AndrewPark: It's slightly complicated. You have to calculate the rect of the last line in the frame, and take the difference between the bottom of that and the top of the space you gave the framesetter to find out how much space it needed.Kingsize
@KevinBallard thanks for revisiting this question and the clarification!Pretender
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 #16586690Dextrality
W
2

Thanks to Chris DeSalvo for the excellent answer! Finally ending 16 hours of debugging. I had a little trouble figuring out the Swift 3 syntax. So sharing the Swift 3 version of setting the paragraph style.

let leading = uiFont.lineHeight - uiFont.ascender + uiFont.descender
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = leading      
mutableAttributedString.addAttribute(NSParagraphStyleAttributeName, value: paragraphStyle, range: textRange)
Walkthrough answered 14/5, 2017 at 19:17 Comment(0)
D
2

Working on this problem and following a lot of different answers from several posters, I had implemented a solution for the all mighty problem of correct text size, for me CTFramesetterSuggestFrameSizeWithConstraints is not working properly so we need to use CTFramesetterCreateFrame and then measure the size for that frame, (this is a UIFont extension) this is swift 100%

References

CTFramesetterSuggestFrameSizeWithConstraints sometimes returns incorrect size?

How to get the real height of text drawn on a CTFrame

Using CFArrayGetValueAtIndex in Swift with UnsafePointer (AUPreset)

func sizeOfString (string: String, constrainedToWidth width: Double) -> CGSize {
        let attributes = [NSAttributedString.Key.font:self]
        let attString = NSAttributedString(string: string,attributes: attributes)
        let framesetter = CTFramesetterCreateWithAttributedString(attString)

        let frame = CTFramesetterCreateFrame(framesetter,CFRange(location: 0,length: 0),CGPath.init(rect: CGRect(x: 0, y: 0, width: width, height: 10000), transform: nil),nil)

        return UIFont.measure(frame: frame)
    }

Then we measure our CTFrame

static func measure(frame:CTFrame) ->CGSize {

        let lines = CTFrameGetLines(frame)
        let numOflines = CFArrayGetCount(lines)
        var maxWidth : Double = 0

        for index in 0..<numOflines {
            let line : CTLine = unsafeBitCast(CFArrayGetValueAtIndex(lines, index), to: CTLine.self)
            var ascent : CGFloat = 0
            var descent : CGFloat = 0
            var leading : CGFloat = 0
            let width = CTLineGetTypographicBounds(line, &ascent, &descent, &leading)

            if(width > maxWidth) {
                maxWidth = width
            }
        }

        var ascent : CGFloat = 0
        var descent : CGFloat = 0
        var leading : CGFloat = 0


        CTLineGetTypographicBounds(unsafeBitCast(CFArrayGetValueAtIndex(lines, 0), to: CTLine.self), &ascent, &descent, &leading)
        let firstLineHeight = ascent + descent + leading

        CTLineGetTypographicBounds(unsafeBitCast(CFArrayGetValueAtIndex(lines, numOflines - 1), to: CTLine.self), &ascent, &descent, &leading)
        let lastLineHeight = ascent + descent + leading

        var firstLineOrigin : CGPoint = CGPoint(x: 0, y: 0)
        CTFrameGetLineOrigins(frame, CFRangeMake(0, 1), &firstLineOrigin);

        var lastLineOrigin : CGPoint = CGPoint(x: 0, y: 0)
        CTFrameGetLineOrigins(frame, CFRangeMake(numOflines - 1, 1), &lastLineOrigin);

        let textHeight = abs(firstLineOrigin.y - lastLineOrigin.y) + firstLineHeight + lastLineHeight

        return CGSize(width: maxWidth, height: Double(textHeight))
    }
Doti answered 30/10, 2019 at 15:35 Comment(0)
B
1

This was driving me batty and none of the solutions above worked for me to calculate layout of long strings over multiple pages and return range values with completely truncated-out lines. After reading the API notes, the parameter in CTFramesetterSuggestFrameSizeWithConstraints for 'stringRange' is thus:

'The string range to which the frame size will apply. The string range is a range over the string that was used to create the framesetter. If the length portion of the range is set to 0, then the framesetter will continue to add lines until it runs out of text or space.'

After setting the string range to 'CFRangeMake(currentLocation, 0)' instead of 'CFRangeMake(currentLocation, string.length)', it all works perfectly.

Botzow answered 25/12, 2020 at 13:53 Comment(0)
L
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)
Ludwigg answered 10/7, 2022 at 12:47 Comment(0)
G
-6

I finally find out.. When uses CTFramesetterSuggestFrameSizeWithConstraints returning CGSize to draw text, the size is considered not big enough(sometimes) for the whole text, so last line is not drew.. I just add 1 to size.height for return, it appears to be right now.

Something like this:

suggestedSize = CGSizeMake(suggestedSize.width, suggestedSize.height + 1);

Grantor answered 7/12, 2012 at 4:30 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.