Core Text Tutorial for iOS: Making a Magazine App

Learn how to make your own magazine app with custom text layout in this Core Text tutorial for iOS. By Lyndsey Scott.

Leave a rating/review
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

Drawing Images in Core Text

Although Core Text can't draw images, as a layout engine, it can leave empty spaces to make room for images. By setting a CTRun's delegate, you can determine that CTRun's ascent space, descent space and width. Like so:

Adding a CTRunDelegate to control ascent and descent spacing

When Core Text reaches a CTRun with a CTRunDelegate it asks the delegate, "How much space should I leave for this chunk of data?" By setting these properties in the CTRunDelegate, you can leave holes in the text for your images.

First add support for the "img" tag. Open MarkupParser.swift and find "} //end of font parsing". Add the following immediately after:

//1
else if tag.hasPrefix("img") { 
      
  var filename:String = ""
  let imageRegex = try NSRegularExpression(pattern: "(?<=src=\")[^\"]+",
                                           options: NSRegularExpression.Options(rawValue: 0))
  imageRegex.enumerateMatches(in: tag, 
    options: NSRegularExpression.MatchingOptions(rawValue: 0), 
    range: NSMakeRange(0, tag.characters.count)) { (match, _, _) in

    if let match = match,
      let range = tag.range(from: match.range) {
        filename = String(tag[range])
    }
  }
  //2
  let settings = CTSettings()
  var width: CGFloat = settings.columnRect.width
  var height: CGFloat = 0

  if let image = UIImage(named: filename) {
    height = width * (image.size.height / image.size.width)
    // 3
    if height > settings.columnRect.height - font.lineHeight {
      height = settings.columnRect.height - font.lineHeight
      width = height * (image.size.width / image.size.height)
    }
  }
}
  1. If tag starts with "img", use a regex to search for the image's "src" value, i.e. the filename.
  2. Set the image width to the width of the column and set its height so the image maintains its height-width aspect ratio.
  3. If the height of the image is too long for the column, set the height to fit the column and reduce the width to maintain the image's aspect ratio. Since the text following the image will contain the empty space attribute, the text containing the empty space information must fit within the same column as the image; so set the image height to settings.columnRect.height - font.lineHeight.

Next, add the following immediately after the if let image block:

//1
images += [["width": NSNumber(value: Float(width)),
            "height": NSNumber(value: Float(height)),
            "filename": filename,
            "location": NSNumber(value: attrString.length)]]
//2
struct RunStruct {
  let ascent: CGFloat
  let descent: CGFloat
  let width: CGFloat
}

let extentBuffer = UnsafeMutablePointer<RunStruct>.allocate(capacity: 1)
extentBuffer.initialize(to: RunStruct(ascent: height, descent: 0, width: width))
//3
var callbacks = CTRunDelegateCallbacks(version: kCTRunDelegateVersion1, dealloc: { (pointer) in
}, getAscent: { (pointer) -> CGFloat in
  let d = pointer.assumingMemoryBound(to: RunStruct.self)
  return d.pointee.ascent
}, getDescent: { (pointer) -> CGFloat in
  let d = pointer.assumingMemoryBound(to: RunStruct.self)
  return d.pointee.descent
}, getWidth: { (pointer) -> CGFloat in
  let d = pointer.assumingMemoryBound(to: RunStruct.self)
  return d.pointee.width
})
//4
let delegate = CTRunDelegateCreate(&callbacks, extentBuffer)
//5
let attrDictionaryDelegate = [(kCTRunDelegateAttributeName as NSAttributedStringKey): (delegate as Any)]              
attrString.append(NSAttributedString(string: " ", attributes: attrDictionaryDelegate))
  1. Append an Dictionary containing the image's size, filename and text location to images.
  2. Define RunStruct to hold the properties that will delineate the empty spaces. Then initialize a pointer to contain a RunStruct with an ascent equal to the image height and a width property equal to the image width.
  3. Create a CTRunDelegateCallbacks that returns the ascent, descent and width properties belonging to pointers of type RunStruct.
  4. Use CTRunDelegateCreate to create a delegate instance binding the callbacks and the data parameter together.
  5. Create an attributed dictionary containing the delegate instance, then append a single space to attrString which holds the position and sizing information for the hole in the text.

Now MarkupParser is handling "img" tags, you'll need to adjust CTColumnView and CTView to render them.

Open CTColumnView.swift. Add the following below var ctFrame:CTFrame! to hold the column's images and frames:

var images: [(image: UIImage, frame: CGRect)] = []

Next, add the following to the bottom of draw(_:):

for imageData in images {
  if let image = imageData.image.cgImage {
    let imgBounds = imageData.frame
    context.draw(image, in: imgBounds)
  }
}

Here you loop through each image and draw it into the context within its proper frame.

Next open CTView.swift and the following property to the top of the class:

// MARK: - Properties
var imageIndex: Int!

imageIndex will keep track of the current image index as you draw the CTColumnViews.

Next, add the following to the top of buildFrames(withAttrString:andImages:):

imageIndex = 0

This marks the first element of the images array.

Next add the following, attachImagesWithFrame(_:ctframe:margin:columnView), below buildFrames(withAttrString:andImages:):

func attachImagesWithFrame(_ images: [[String: Any]],
                           ctframe: CTFrame,
                           margin: CGFloat,
                           columnView: CTColumnView) {
  //1
  let lines = CTFrameGetLines(ctframe) as NSArray
  //2
  var origins = [CGPoint](repeating: .zero, count: lines.count)
  CTFrameGetLineOrigins(ctframe, CFRangeMake(0, 0), &origins)
  //3
  var nextImage = images[imageIndex]
  guard var imgLocation = nextImage["location"] as? Int else {
    return
  }
  //4
  for lineIndex in 0..<lines.count {
    let line = lines[lineIndex] as! CTLine
    //5
    if let glyphRuns = CTLineGetGlyphRuns(line) as? [CTRun], 
      let imageFilename = nextImage["filename"] as? String, 
      let img = UIImage(named: imageFilename)  { 
        for run in glyphRuns {

        }
    }
  }
}
  1. Get an array of ctframe's CTLine objects.
  2. Use CTFrameGetOrigins to copy ctframe's line origins into the origins array. By setting a range with a length of 0, CTFrameGetOrigins will know to traverse the entire CTFrame.
  3. Set nextImage to contain the attributed data of the current image. If nextImage contain's the image's location, unwrap it and continue; otherwise, return early.
  4. Loop through the text's lines.
  5. If the line's glyph runs, filename and image with filename all exist, loop through the glyph runs of that line.

Next, add the following inside the glyph run for-loop:

// 1
let runRange = CTRunGetStringRange(run)    
if runRange.location > imgLocation || runRange.location + runRange.length <= imgLocation {
  continue
}
//2
var imgBounds: CGRect = .zero
var ascent: CGFloat = 0       
imgBounds.size.width = CGFloat(CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &ascent, nil, nil))
imgBounds.size.height = ascent
//3
let xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, nil)
imgBounds.origin.x = origins[lineIndex].x + xOffset 
imgBounds.origin.y = origins[lineIndex].y
//4
columnView.images += [(image: img, frame: imgBounds)]
//5
imageIndex! += 1
if imageIndex < images.count {
  nextImage = images[imageIndex]
  imgLocation = (nextImage["location"] as AnyObject).intValue
}
  1. If the range of the present run does not contain the next image, skip the rest of the loop. Otherwise, render the image here.
  2. Calculate the image width using CTRunGetTypographicBounds and set the height to the found ascent.
  3. Get the line's x offset with CTLineGetOffsetForStringIndex then add it to the imgBounds' origin.
  4. Add the image and its frame to the current CTColumnView.
  5. Increment the image index. If there's an image at images[imageIndex], update nextImage and imgLocation so they refer to that next image.

Image depicting the margin, pageRect and line origin information from the above explanation

OK! Great! Almost there - one final step.

Add the following right above pageView.addSubview(column) inside buildFrames(withAttrString:andImages:) to attach images if they exist:

if images.count > imageIndex {
  attachImagesWithFrame(images, ctframe: ctframe, margin: settings.margin, columnView: column)
}

Build and run on both iPhone and iPad!

Congrats! As thanks for all that hard work, the zombies have spared your brains! :]