Home > cocoa > Wrapping text around a shape with CoreText

Wrapping text around a shape with CoreText

March 25th, 2011 Leave a comment Go to comments

CoreText is a very powerful system for laying out text in arbitrary ways. This is going to be a bit of a whirlwind tour of it to help out nonamelive on StackOverflow. I’m working on an advanced iOS book right now, and I’ll have a longer writeup there. The primary tool for this project is the CTFramesetter. Its job is to layout runs of text into rectangles. So to use it, you need to break up your text areas into rectangles. I hinted at some of the tools to do that in Clipping a CGRect to a CGPath. Your first job is to create an array of CGPaths that describe the area you want to fill with text. For nonamelive’s case, that’s pretty easy using CGPathAddLineToPoint. The only tricky part for my example code is that you need to draw in Mac coordinates (with the origin in the lower left). CoreText works in Mac coordinates, and so it’s generally easier to do everything that way for the view.

Once you have your arbitrary paths, you want to break them down into rectangles. I’ve written some code to do this that assumes a known hight for the text lines. Things get more complicated if you have text that includes different font sizes (you need to do some guessing, then try to layout, and go back and correct things if you were wrong).

The goal of this code is to walk down the path boundary, trying to grow an ever-larger rectangle. If the width or the offset of the boundary changes, then it starts a new rectangle.

- (CFArrayRef)copyRectangularPathsForPath:(CGPathRef)path 
                                   height:(CGFloat)height {
    CFMutableArrayRef paths = CFArrayCreateMutable(NULL, 0, 
                                                   &kCFTypeArrayCallBacks);

    // First, check if we're a rectangle. If so, we can skip the hard parts.
    CGRect rect;
    if (CGPathIsRect(path, &rect)) {
        CFArrayAppendValue(paths, path);
    }
    else {
        // Build up the boxes one line at a time. If two boxes have the 
        // same width and offset, then merge them.
        CGRect boundingBox = CGPathGetPathBoundingBox(path);
        CGRect frameRect = CGRectZero;
        for (CGFloat y = CGRectGetMaxY(boundingBox) - height; 
                     y > height; y -= height) {
            CGRect lineRect =
                   CGRectMake(CGRectGetMinX(boundingBox), y, 
                              CGRectGetWidth(boundingBox), height);
            CGContextAddRect(UIGraphicsGetCurrentContext(), lineRect);

            // Do the math with full precision so we don't drift, 
            // but do final render on pixel boundaries.
            lineRect = CGRectIntegral(clipRectToPath(lineRect, path));
            CGContextAddRect(UIGraphicsGetCurrentContext(), lineRect);

            if (! CGRectIsEmpty(lineRect)) {
                if (CGRectIsEmpty(frameRect)) {
                    frameRect = lineRect;
                }
                else if (frameRect.origin.x == lineRect.origin.x && 
                         frameRect.size.width == lineRect.size.width) {
                    frameRect = CGRectMake(lineRect.origin.x,                                                                                                                                      lineRect.origin.y,                                                                                                                                      lineRect.size.width, 
                                CGRectGetMaxY(frameRect) - CGRectGetMinY(lineRect));
                }
                else {
                    CGMutablePathRef framePath =
                                         CGPathCreateMutable();
                    CGPathAddRect(framePath, NULL, frameRect);
                    CFArrayAppendValue(paths, framePath);

                    CFRelease(framePath);
                    frameRect = lineRect;
                }
            }
        }

        if (! CGRectIsEmpty(frameRect)) {
            CGMutablePathRef framePath = CGPathCreateMutable();
            CGPathAddRect(framePath, NULL, frameRect);
            CFArrayAppendValue(paths, framePath);
            CFRelease(framePath);
        }           
    }

    return paths;
}

Remember, the coordinate system here is flipped for iOS. So CGRectGetMaxY() is returning the top of the box, not the bottom. The call to clipRectToPath() is from my previous post on clipping rectangles to paths. Also noteworthy here is our use of UIGraphicsGetCurrentContext(). This routine is meant to be called inside of drawRect:.

There are some inefficiencies here. More efficient approaches would calculate the rectangles a single time, rather than during drawRect: (though this isn’t as inefficient as it sounds, since we try to avoid calling drawRect: more than we need). A better implementation could avoid some of the memory churn in clipRectToPath() by allocating a single large buffer. But this hopefully is a reasonable example of how to attack the problem.

An example Xcode project is attached.

Columns.zip

Categories: cocoa Tags:
  1. nonamelive
    March 26th, 2011 at 02:20 | #1

    This is awesome! Thank you so much for this solution.

    Btw, I added two lines of code in drawRect to make the text readable.

    CGAffineTransform flip = CGAffineTransformMake(1.0, 0.0, 0.0, -1.0, 0.0, self.frame.size.height); CGContextConcatCTM(context, flip);

    • March 26th, 2011 at 10:18 | #2

      If you look in my initializer, you should see something very similar applied to the view transform. That’s sometimes more convenient than flipping the context on every draw.

  2. nonamelive
    March 27th, 2011 at 08:19 | #3

    Hi, could you please explain what the height parameter means in the following code:

    • (CFArrayRef)copyRectangularPathsForPath:(CGPathRef)path height:(CGFloat)height

    Is this the height of a single line text (font height)?

    Thank you!

  3. March 28th, 2011 at 13:39 | #4

    @nonamelive Correct. That’s the height of a single line of text. It’s a simplification and optimization so the code doesn’t have to try to work out the line height from the attributed string. In the current code it’s hard-coded to 18.0. In more production code, it should probably be a property on the view. And for more flexibility, it could be calculated from the attributed string (though this adds complexity).

  4. Dave K.
    April 24th, 2011 at 11:37 | #5

    let’s say i was making an ipad storybook app, and i wanted rich formatted text to not just flow around regions, but to be hot as well… i want to be able to define certain characters as buttons so that when a user touches anything, various things could happen such as the word highlights, grows larger momentarily, and is read aloud…. or various other things. I would like the text regions on the page to be in a plist along with the text and the photo associated with that page… how would i extend what you’ve done here to accomplish that?

  5. Dave K.
    April 24th, 2011 at 11:39 | #6

    @Dave K.

    oh and also, will this handle noncontiguous regions?

  6. May 10th, 2011 at 16:44 | #7

    @Dave K. This will handle non-contiguous regions. That’s not particularly difficult. You already need to hand rectangles to the CTFramesetter. It doesn’t care if they’re contiguous or not.

    Handling touches is a little more challenging. You need to keep figure out where the glyphs are. After you create the CTFrameRef, you’ll want to use CTFrameGetVisibleStringRange() and compare it against your original string to figure out if anything you care about intersects this frame. If so, you’ll want to pull out the CTLineRefs, figure out which one (or ones) include your interesting characters (CTLineGetStringRange()).

    From there, you’ll extract the correct CTRunRefs with CTGetGlyphRuns() and map characters to glyphs using CTRunGetStringIndices(). Then use CTRunGetPositions() to find the locations of those glyphs. And finally (finally!) use CTRunGetTypographicBounds() to find the ascent, descent and leading for the run (so you can map the bounding box).

    Once you’ve done all that, you can figure out if touch events intersect any of your interesting boxes.

    Alternately, you may find it more convenient to skip the CTFramesetter altogether and layout the CTRuns yourself using a CTTypesetter, or even skip that and create the CTLines directly if you already know where to break things.

  7. Patrick
    June 22nd, 2011 at 10:39 | #8

    Thanks for this article. In the demo app “TextDemo”, the text is flipped and it is not readable.

    In the first comment in this page, a guy proposed to flip the content in drawRect, but you answered it is not efficient, and you suggested to check your initializer. I checked it but I couldn’t find anything useful. Could you explain me what’s the best solution and why the content is flipped in the original demo ?

    thanks

  8. August 11th, 2011 at 16:46 | #9

    The code I was discussing is in finishInit which is called by init. It applies the transform to the view if targeting the iPhone.

  9. Infinite
    August 15th, 2011 at 12:57 | #10

    How work-intensive is this code for a mobile device such as the iPhone? I’m thinking about doing this wrapping in a UITableViewCell Another occurring problem here is: How would i recalculate the Height of the resulting text? In your example you just use the whole screen, but this isn’t possible in a variable Height-Cell :(

  10. Infinite
    August 15th, 2011 at 12:58 | #11

    @Infinite Sorry, damn autocorrect…

    i meant to say “How would you pre-calculate the height?”

  11. August 15th, 2011 at 14:10 | #12

    Core Text is a low-level framework and is very fast compared to higher-level frameworks like UIKit, so the layout is fast. This particular technique of using a bitmap to wrap around is somewhat expensive, so you need to make sure to cache your answers appropriately. The best approach is to use Instruments to profile the code and look for the bottlenecks.

    The height here is the height of a single line of text, not the height of the entire box. You should also look at Laying out text with Core Text for more information.

  12. adam
    August 22nd, 2011 at 15:49 | #13

    So how do you add an image to get wrapped?

  13. August 22nd, 2011 at 16:27 | #14

    You draw the image yourself in -drawRect:. The point of this code is to work out the text layout given an existing CGPath that circumscribes the image. Building that CGPath is either very easy (when it’s mostly rectangular) or quite tricky (when it’s a funky shape). In the case that it’s complex, you can look at Clipping a CGRect to a CGPath for a technique that could be applied to an image that includes a known background color or an alpha channel. clipRectToPath basically draws an image and then clips a rectangle to it. If you already had an image, you could use the same technique, while skipping the drawing step. Of course that clips to exactly the image. Most applications would want a margin around the image, so you’d need to adjust for that.

    I’m just about done with the “advanced iOS book” I mention at the top of this post. I’ll be blogging more about it shortly, but it’ll cover Core Text and these kinds of fancy layouts.

  14. adam
    August 27th, 2011 at 20:46 | #15

    @Rob Napier Thanks for the reply. I’m definitely going to pick up your book since you’ve helped me many times on stack ;)

  15. christian
    October 11th, 2011 at 08:50 | #16

    @Rob Napier I’ve downloaded the attached example, but finishInit is empty.

  16. October 13th, 2011 at 14:05 | #17

    @christian finishInit is part of a standard pattern I use to deal with nib-loaded objects. It is called from both initWithFrame: and awakeFromNib. In this case there is no additional initialization needed.

  17. @Michael Z.
    November 29th, 2011 at 13:11 | #18

    @Rob Napier Hi Rob, I have a text drawn on a view that is added to a scrollview(the text comes in different sizes). My problem is that the text is not drawn below (0, 0) point on Y axis if I have a longer string. Can you give me an advice on what to do on this situation?. Thanks.

  18. November 29th, 2011 at 17:18 | #19

    @@Michael Z. What do you mean by “is not drawn below (0,0) point?” Do you mean it’s not wrapping (all on one long string), or that the text is truncated? Or something else?

  19. @Michael Z.
    November 30th, 2011 at 03:48 | #20

    @Rob Napier The string is truncated. I managed a partial fix by enlarging the view where is drawn, but I need to know if I can set the text to be drawn from (x, – 100) – below the view’s frame. Thanks again.

  20. November 30th, 2011 at 09:38 | #21

    @@Michael Z.

    The problem you’re seeing is likely in how you’re setting up your text path for the framesetter. You probably are passing the view’s frame to the framesetter and asking it to fill it. But you really want to pass something larger than the frame. Once the framesetter fills the path you hand it, it stops adding text. You’ll need to decide how large a box you want the framesetter to fill. It is legal (and sensible) to pass CGFLOAT_MAX as the hight of your frame if you want no limit.

    Typically, of course, views do not draw outside their frames. Most of them do not clip to their frame, but UIScrollView does by default. You can configure this with setClipsToBounds:. Note that this can create some UI confusion if not done very carefully. Hit testing is done using the frame, so things drawn outside of the scroll view’s frame cannot be dragged to scroll the view. Whenever possible, you should keep things inside their frames.

  21. amol
    July 10th, 2012 at 05:56 | #22

    hi Rob,

    I want to display same pattern like “nonamelive”. I downloaded zip file provided by you but it gives error “SIGABRT” at following line.

    NSAttributedString *attrString = [[[NSAttributedString alloc] initWithString:kLipsum attributes:attributes1] autorelease];

    thanks..

  22. amol
    July 10th, 2012 at 07:43 | #23

    its done..thanks

  23. October 30th, 2012 at 14:23 | #24

    Hi… i want to show text around and arbitrary path, so two questions 1) how to we find shape/path of a given image?

    2) how do we wrap text around that path?

    • October 30th, 2012 at 15:03 | #25

      When you say “show around an arbitrary path,” I assume you mean drawing it along a curve. Converting an image to a curve is a non-trivial problem. If the image isn’t already masked with transparency, then you’re going to have to start with edge detection, which is its own subject. Then matching a curve to even a very simple bitmap is its own subject again.

      Once you have a mathematical definition of your curve, see the CurvyText example from iOS Programming Pushing the Limits for how to lay out text along a mathematical formula.

  24. December 12th, 2012 at 11:01 | #26

    Hi Rob,

    I can’t seem to get the example columns project to work, it just crashes with EXC_BAD_ACCESS. In RNTextView.m, it has the following:

    • (id)initWithFrame:(CGRect)frame { self = [super initWithFrame:frame]; if (self != nil) { [self finishInit]; } return self; }

    • (void)awakeFromNib { [self finishInit]; }

    • (void)finishInit {

    }

    What is this finishInit?

    I really, really need to format text in exactly the same way as nonamelive on StackOverflow, but don’t really understand your code, and have only just started looking into CoreText. I’ve also tried looking at this tutorial http://www.raywenderlich.com/4147/how-to-create-a-simple-magazine-app-with-core-text but can’t adapt things to my requirements.

    Any tips would be much appreciated, Thanks

  25. December 12th, 2012 at 11:54 | #27

    finishInit is a thing I often drop into views so that I can apply the same initialization whether the view is created programmatically or from a nib file. As you see, it’s empty in this one. If you’re looking for columns that work on iOS 6, see https://github.com/rnapier/richtext-coretext/tree/master/Columns. Tap the screen to see different path layouts. This is my most recent example project from my Rich Text, Core Text talk at CocoaConf 2012. This code is discussed in detail in Chapter 26 of iOS:PTL (http://iosptl.com). You can use this code to fill any paths you want. Just return the paths in copyPaths.

  26. Aly Lero
    February 1st, 2013 at 18:43 | #28

    Hi,

    In the pathsForMode: method of the ColumnsAppDelegate class, I tried to created a single column display with this path. But it does not work. The right right is missing as the result.

            CGMutablePathRef path = CGPathCreateMutable();
            CGPathMoveToPoint(path, NULL, 30, 30);
    CGPathAddLineToPoint(path, NULL, 700, 30);

        CGPathAddLineToPoint(path, NULL, 700, 800);
        CGPathAddLineToPoint(path, NULL, 500, 800);
        CGPathAddLineToPoint(path, NULL, 500, 400);
        CGPathAddLineToPoint(path, NULL, 200, 400);
        CGPathAddLineToPoint(path, NULL, 200, 800);
        CGPathAddLineToPoint(path, NULL, 500, 800);
    
        CGPathAddLineToPoint(path, NULL, 700, 800);
    
        CGPathAddLineToPoint(path, NULL, 700, 944);
        CGPathAddLineToPoint(path, NULL, 30, 944);
    
        CGPathCloseSubpath(path);
        [paths addObject:(id)path];
        CFRelease(path);
    

    • February 1st, 2013 at 21:41 | #29

      Your path is a bit strange and seems to overlap itself. You may want to draw your path and make sure it’s really what you mean. You may also want to try a simpler path and build up from that.

  1. No trackbacks yet.