How does line spacing work in Core Text? (and why is it different from NSLayoutManager?)
Asked Answered
D

3

30

I'm trying to draw text using Core Text functions, with a line spacing that's as close as possible to what it would be if I used NSTextView.

Take this font as an example:

NSFont *font = [NSFont fontWithName:@"Times New Roman" size:96.0];

The line height of this font, if I would use it in an NSTextView is 111.0.

NSLayoutManager *lm = [[NSLayoutManager alloc] init];
NSLog(@"%f", [lm defaultLineHeightForFont:font]); // this is 111.0

Now, if I do the same thing with Core Text, the result is 110.4 (assuming you can calculate the line height by adding the ascent, descent and leading).

CTFontRef cFont = CTFontCreateWithName(CFSTR("Times New Roman"), 96.0, NULL);
NSLog(@"%f", CTFontGetDescent(cFont) + CTFontGetAscent(cFont) + 
             CTFontGetLeading(cFont)); // this is 110.390625

This is very close to 111.0, but for some fonts the difference is much bigger. E.g. for Helvetica, NSLayoutManager gives 115.0 whereas CTFont ascent + descent + leading = 96.0. Clearly, for Helvetica, I wouldn't be able to use ascent + descent + leading to calculate the spacing between lines.

So I thought I'd use CTFrame and CTFramesetter to layout a few lines and get the linespacing from that. But that also gives different values.

CTFontRef cFont = CTFontCreateWithName(CFSTR("Times New Roman"), 96.0, NULL);
NSDictionary *attrs = [NSDictionary dictionaryWithObject:(id)cFont forKey:(id)kCTFontAttributeName];
NSAttributedString *threeLines = [[NSAttributedString alloc] initWithString:@"abcdefg\nabcdefg\nabcdefg" attributes:attrs];

CTFramesetterRef threeLineFramesetter =  CTFramesetterCreateWithAttributedString((CFAttributedStringRef)threeLines);
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, CGRectMake(0.0, 0.0, 600.0, 600.0));
CTFrameRef threeLineFrame = CTFramesetterCreateFrame(threeLineFramesetter, CFRangeMake(0, 0), path, NULL);

CGPoint lineOrigins[3];
CTFrameGetLineOrigins(threeLineFrame, CFRangeMake(0, 0), lineOrigins);
NSLog(@"space between line 1 and 2: %f", lineOrigins[0].y - lineOrigins[1].y); // result: 119.278125
NSLog(@"space between line 2 and 3: %f", lineOrigins[1].y - lineOrigins[2].y); // result: 113.625000

So the line spacing is now even more different from the 111.0 that was used in my NSTextView, and not every line is equal. It seems that the line breaks add some extra space (even though the default value for paragraphSpacingBefore is 0.0).

I'm working around this problem now by getting the line height via NSLayoutManager and then individually drawing each CTLine, but I wonder if there's a better way to do this.

Dermott answered 1/4, 2011 at 10:1 Comment(0)
B
50

OK, so I took a good look at what goes on in the guts of NSLayoutManager, and it appears, based on my reading of the disassembly, that the code it uses boils down to something like this:

CGFloat ascent = CTFontGetAscent(theFont);
CGFloat descent = CTFontGetDescent(theFont);
CGFloat leading = CTFontGetLeading(theFont);

if (leading < 0)
  leading = 0;

leading = floor (leading + 0.5);

lineHeight = floor (ascent + 0.5) + floor (descent + 0.5) + leading;

if (leading > 0)
  ascenderDelta = 0;
else
  ascenderDelta = floor (0.2 * lineHeight + 0.5);

defaultLineHeight = lineHeight + ascenderDelta;

This will get you the 111.0 and 115.0 values for the two fonts you mention above.

I should add that the correct way, according to the OpenType specification, is just to add the three values (being careful, if you’re using an API that doesn’t make them all positive, to get the sign of the descent value correct).

Belligerent answered 12/4, 2011 at 13:29 Comment(5)
CoreText doesn't always add the ascender delta even if the leading is zero. I haven't managed to figure out exactly what determines whether it's added or not. At first it seemed it's different between OpenType and TrueType, but I found exceptions in both.Socratic
From testing the leading does not get set to 0 if inferior to 0. I would also like to point out that this calculation only is the case in iOS6 and before.Hire
@Hire The code above is based on the actual code in OS X on the date the answer was written, so it actually does get set to 0 if it’s below zero. This question was about OS X, not iOS, though, and on iOS the text rendering system is different, so it would not surprise me if the calculation iOS uses is not the same.Belligerent
@alastair oh yes, I'm sorry, though I would have thought that core text would have used the same calculation on iOS and OSX at the time.Hire
Resurrecting the dead, but I wonder how these findings I just did relate to anything...Bromeosin
Y
4

simple. set up a test string and frame and compare origin of two lines of the font you want. Then if you want to calculate leading just use line height accent descent to do the calculation.

    - (float)getLineHeight {


        CFMutableAttributedStringRef testAttrString;
        testAttrString = CFAttributedStringCreateMutable(kCFAllocatorDefault, 0);
        NSString *testString = @"testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest";
        CFAttributedStringReplaceString (testAttrString, CFRangeMake(0, 0), (CFStringRef)testString);

        CTFontRef myFont1 = CTFontCreateWithName((CFStringRef)@"Helvetica", 30, NULL);
        CFRange range = CFRangeMake(0,testString.length);
        CFAttributedStringSetAttribute(testAttrString, range, kCTFontAttributeName, myFont1);

        CGMutablePathRef path = CGPathCreateMutable();
        CGRect bounds;
        if ([model isLandscape]) {
            bounds = CGRectMake(0, 10, 1024-20, 768);
        }
        else {
            bounds = CGRectMake(0, 10, 768-20, 1024);
        }    
        CGPathAddRect(path, NULL, bounds);

        CTFramesetterRef testFramesetter = CTFramesetterCreateWithAttributedString(testAttrString);
        CTFrameRef testFrameRef = CTFramesetterCreateFrame(testFramesetter,CFRangeMake(0, 0), path, NULL);
        CGPoint origins1,origins2;
        CTFrameGetLineOrigins(testFrameRef, CFRangeMake(0, 1), &origins1);
        CTFrameGetLineOrigins(testFrameRef, CFRangeMake(1, 1), &origins2);
        return origins1.y-origins2.y;
    }
Yaya answered 7/12, 2012 at 19:6 Comment(1)
Cool, this works! I changed it some as I was subclassing UITextView and already had a preexisting CTFrameRef which made things a bit easier but this is a great way of always getting an accurate calculation of the line height for any font at any point size with any scale (for those who need to account for a view's scale just add a CGFloat scale parameter and change the return line to: return (origins1.y - origins2.y) * scale;. Many thanks, user1307179!Vigorous
B
1

Have you looked to see what the sign of the value returned by CTFontGetDescent() is? A common mistake is to assume that descent values are positive, when in fact they tend to be negative (to reflect the fact that they are a descent below the font baseline).

As a result, line spacing should probably be set to

ascent - descent + leading
Belligerent answered 8/4, 2011 at 10:39 Comment(2)
I've checked this with a few different fonts and CTFontGetDescent() always returned a positive value. E.g. the font in my sample code has a descent of 20.77.Dermott
Interesting, you’re right… apparently Core Text negates the values from the hhea table in the font.Belligerent

© 2022 - 2024 — McMap. All rights reserved.