Skip to content
macOSDeep Dive Published Updated 8 min readViews unavailable

Core Text on macOS: Typesetting, Frames, Glyph Runs, and Hit Testing

Build predictable Core Text layout by separating Unicode input, shaping, line breaking, frame geometry, drawing transforms, and text hit testing.

Core Text is Apple’s low-level text layout and font technology. It is useful when an application needs to measure, paginate, draw, or inspect styled text without delegating the entire job to an AppKit text view. Its model is not “one character becomes one positioned glyph.” Text contains Unicode scalars and grapheme clusters; a shaping engine maps character sequences to glyphs, chooses fonts, applies ligatures and positioning, and produces runs and lines. Layout then places those lines into frames. Each stage has its own coordinate, indexing, and lifetime contract.

Most document editors should begin with NSTextView or other higher-level text system APIs. Core Text is appropriate for custom layout engines, generated reports, text in graphics, pagination, specialized measurement, and interfaces where the standard text view does not express the needed presentation. It is powerful precisely because it leaves many decisions to the caller.

Model the pipeline instead of counting characters

Start with a CFAttributedString or Swift attributed string whose attributes use Core Text keys and values. At a minimum, specify a font for the text you need to measure. Paragraph style, baseline offset, writing direction, kern, ligature behavior, and color can affect output. A CTFramesetter is convenient when the objective is to place multiline content into a path. It encapsulates a typesetter, proposes a frame size, and creates a CTFrame containing lines and their glyph runs.

The data model should preserve the original string and associate layout output with the exact text revision and attributes from which it was derived. Do not cache a CTLine and later interpret its string indices against a replacement string. A line stores ranges into the attributed text used to create it. If the input is edited, rebuild the affected layout and invalidate associated hit-test results.

import AppKit
import CoreText

func makeTextFrame(_ text: String, width: CGFloat) -> CTFrame? {
    let font = CTFontCreateWithName("HelveticaNeue" as CFString, 14, nil)
    let attributes: [NSAttributedString.Key: Any] = [
        NSAttributedString.Key(kCTFontAttributeName as String): font
    ]
    let attributed = NSAttributedString(string: text, attributes: attributes)
    let framesetter = CTFramesetterCreateWithAttributedString(attributed)
    let path = CGPath(rect: CGRect(x: 0, y: 0, width: width, height: 10_000),
                      transform: nil)
    return CTFramesetterCreateFrame(
        framesetter,
        CFRange(location: 0, length: attributed.length),
        path,
        nil
    )
}

This creates a Core Text frame in a Core Graphics path. It does not create an AppKit text view or choose a window coordinate system. The path and the drawing context have to use compatible coordinates. The large height is a deliberate layout bound, not a measurement of the rendered text; production code should size the path for its actual page or view and inspect the visible range and frame bounds.

Unicode, indices, shaping, and fallback

Core Text’s string ranges use CFIndex offsets into the attributed string’s UTF-16 representation. Swift’s String.Index, Unicode scalar indices, grapheme-cluster boundaries, UTF-16 offsets, and glyph positions are not interchangeable. A user-perceived character can comprise multiple scalars, and shaping can map several characters to one glyph or one character to multiple glyphs. Never use a glyph index as a string offset or advance a Swift string index by assuming one unit per displayed character.

Use Core Text’s line APIs to translate between a point and a string index, then convert that UTF-16 offset through a deliberate bridging strategy if the surrounding model uses Swift string indices. Treat a returned index as a location in the source text, not as a glyph number. A click near a ligature, combining mark, bidirectional run, or truncated line can have ambiguous product semantics; test the desired caret or selection behavior with actual scripts and fonts.

Font fallback is part of shaping. A requested font may not contain every needed character, so Core Text can use a cascade to select fonts that cover missing characters. This is why reading a single run’s font or creating a glyph by manually mapping a Unicode scalar is not a reliable way to reproduce the rendered text. If a product requires a specific script or brand typeface, package and license appropriate fonts, validate their coverage, and still define a fallback for missing glyphs.

Lines, frames, and pagination

Use CTTypesetter when an application needs direct control over line creation. CTTypesetterSuggestLineBreak and related APIs can propose breakpoints; they do not replace the need to preserve the resulting line’s range and measure its typographic bounds. Hyphenation, line-break opportunities, paragraph attributes, writing direction, and cluster boundaries influence where a legal break can occur. Do not split at arbitrary UTF-16 offsets simply to fill a rectangle.

For paginated output, create one frame per page from the range that the previous frame actually consumed. Core Text exposes the visible string range for a frame. Use that range to advance the next page, and assert progress to avoid an infinite pagination loop when the path is too small or an input contains unusual attributes. Preserve the same attributed input and layout policy across pages so a line does not change because page construction accidentally used a different font or paragraph style.

Frame paths define the region in which line layout is allowed. A rectangular path is straightforward, but a nonrectangular path can affect line fragments and placement. Distinguish the path’s geometric bounds from a line’s typographic ascent, descent, and leading. A line’s advance width is not automatically the same as its visual bounding box, especially when glyphs overhang their advances or when text includes attachments.

Coordinate systems and drawing

Core Graphics uses a lower-left origin by default, and an NSView is also non-flipped by default: its origin is lower-left and positive y increases upward. A view can override isFlipped to use an upper-left origin with positive y increasing downward. Check the actual view and graphics-context state rather than assuming AppKit is top-left oriented. The coordinate system used to create a frame must match the transform used to draw it. Applying an undocumented “flip” because text appears upside down can fix one view while breaking hit testing or pagination elsewhere.

For an AppKit custom view, choose a clear policy: lay out in a defined text coordinate space, apply a single transform when drawing, and convert pointer locations into that same space before testing lines. A printed PDF context and an AppKit view may require different transforms. Test baseline position, selection rectangles, and click mapping together; visual correctness alone does not prove that hit testing uses the same geometry.

CTFrameDraw draws the lines in the frame into the current graphics context. It does not handle every editor concern: selection, caret blinking, links, spell checking, accessibility, input methods, and text editing are separate responsibilities. If the user needs editable text and standard macOS keyboard behavior, prefer AppKit’s text system and use Core Text only for a bounded custom rendering task.

Threading and object ownership

Apple documents that individual Core Text functions are thread-safe and that font objects such as CTFont and CTFontDescriptor can be used by simultaneous operations. It separately documents that layout objects including CTTypesetter, CTFramesetter, CTRun, CTLine, and CTFrame should be used in a single operation, work queue, or thread. Do not interpret “thread-safe functions” as permission to share every returned layout object across unrelated concurrent jobs.

An effective design makes each layout pass own its framesetter, frame, lines, and runs on one queue or actor. Pass immutable source values and font descriptions into that worker, then return a value snapshot containing the geometry and ranges needed by the UI. If drawing objects must remain alive across callbacks, retain them under one owner and synchronize access. Rebuild when the input revision, width, paragraph style, display scale, or font availability changes.

Avoid expensive full-document layout on the main thread while typing. Coalesce rapid edits, lay out visible or near-visible ranges where the architecture permits, and use background work only with a clear handoff to the UI. A stale background result must not replace a newer layout; tag requests with a revision and discard obsolete completions.

Diagnostics and measurable acceptance

Log the input revision, text length in UTF-16 units, frame path dimensions, visible range, line count, and elapsed layout time. Do not log document contents. If output clips, compare the requested range with the frame’s visible range and inspect line typographic bounds before changing font sizes. If selection is offset, trace the conversion from pointer coordinate to Core Text line to UTF-16 string index to model range.

Test Latin text with ligatures, combining marks, emoji sequences, right-to-left paragraphs, mixed scripts, missing glyphs, empty text, very long unbroken tokens, narrow paths, and rapid edits. Test at several widths and on both screen and PDF contexts. Require that pagination advances monotonically and eventually consumes the intended text; require that hit-test output falls within the line’s source range; and compare layout revisions so stale async work never wins.

Core Text produces sophisticated shaping and line layout, but it cannot infer an application’s editing policy. The application must define how source ranges map to selection, how lines map to pages, which coordinate system is canonical, and how stale layout is invalidated.

Related:

Sources:

Comments