apple-text-core-text
Use when working with Core Text for glyph-level access, custom typesetting, hit testing, font tables, or when TextKit 2 lacks the glyph APIs you need
Core Text for TextKit Developers
Use this skill when you need glyph-level control — either because TextKit 2 has no glyph APIs, or because your use case (custom typesetting, font tables, per-glyph rendering) requires the Core Text layer directly.
When to Use
- You need glyph positions, advances, or bounding boxes that TextKit 2 hides
- You are doing custom typesetting or non-standard line breaking
- You need font table access or OpenType feature control
- You are rendering text with custom Core Graphics effects per glyph
- You need hit testing or caret positioning outside of a TextKit text container
Quick Decision
Need glyph-level access?
TextKit 1 available? → Use NSLayoutManager glyph APIs
TextKit 2 only? → Drop to Core Text (this skill)
Need custom line breaking?
→ CTTypesetter
Need to draw text into a CGContext directly?
→ CTLine or CTFrame
Need font metrics, tables, or OpenType features?
→ CTFont
Need inline non-text elements with custom metrics?
→ CTRunDelegate
Core Guidance
Architecture
CTFramesetter (factory)
→ CTTypesetter (line breaking)
→ CTFrame (laid-out region)
→ CTLine (one visual line)
→ CTRun (contiguous glyphs, same attributes)
→ CGGlyph[] (actual glyph IDs)
→ CGPoint[] (positions)
→ CGSize[] (advances)
Core Text sits directly above Core Graphics. It is a C API using Core Foundation types. Available since iOS 3.2 / macOS 10.5. Both TextKit 1 and TextKit 2 render through Core Text internally — it is the foundation under both, not part of either.
Thread safety: Font objects (CTFont, CTFontDescriptor, CTFontCollection) are thread-safe and can be shared across threads. Layout objects (CTTypesetter, CTFramesetter, CTRun, CTLine, CTFrame) are NOT thread-safe — use them on a single thread only.
CTLine — The Most Common Escape Hatch
For most TextKit developers, CTLine is the entry point. Create one from an attributed string to get glyph information:
let attributedString = NSAttributedString(string: "Hello 👋🏽",
attributes: [.font: UIFont.systemFont(ofSize: 16)])
let line = CTLineCreateWithAttributedString(attributedString)
// Typographic bounds
var ascent: CGFloat = 0, descent: CGFloat = 0, leading: CGFloat = 0
let width = CTLineGetTypographicBounds(line, &ascent, &descent, &leading)
let height = ascent + descent + leading
// Hit testing: point → string index
let index = CTLineGetStringIndexForPosition(line, CGPoint(x: 50, y: 0))
// Caret positioning: string index → x offset
let offset = CTLineGetOffsetForStringIndex(line, 3, nil)
// Image bounds (actual rendered pixels, not typographic)
let ctx = UIGraphicsGetCurrentContext()!
let imageBounds = CTLineGetImageBounds(line, ctx)
CTRun — Glyph-Level Access
Each CTRun is a contiguous sequence of glyphs sharing the same attributes:
let runs = CTLineGetGlyphRuns(line) as! [CTRun]
for run in runs {
let glyphCount = CTRunGetGlyphCount(run)
// Get all glyphs
var glyphs = [CGGlyph](repeating: 0, count: glyphCount)
CTRunGetGlyphs(run, CFRange(location: 0, length: glyphCount), &glyphs)
// Get positions (relative to line origin)
var positions = [CGPoint](repeating: .zero, count: glyphCount)
CTRunGetPositions(run, CFRange(location: 0, length: glyphCount), &positions)
// Get advances (width of each glyph)
var advances = [CGSize](repeating: .zero, count: glyphCount)
CTRunGetAdvances(run, CFRange(location: 0, length: glyphCount), &advances)
// Map glyph indices back to string indices (UTF-16)
var stringIndices = [CFIndex](repeating: 0, count: glyphCount)
CTRunGetStringIndices(run, CFRange(location: 0, length: glyphCount), &stringIndices)
// Get the run's attributes
let attrs = CTRunGetAttributes(run) as! [NSAttributedString.Key: Any]
let font = attrs[.font] as! CTFont
}
CTFramesetter / CTFrame — Multi-Line Layout
For laying out text into a rectangular (or custom-shaped) region:
let framesetter = CTFramesetterCreateWithAttributedString(attributedString)
// Suggest frame size for constrained width
let constraint = CGSize(width: 300, height: .greatestFiniteMagnitude)
let suggestedSize = CTFramesetterSuggestFrameSizeWithConstraints(
framesetter, CFRange(location: 0, length: 0), nil, constraint, nil)
// Create frame in a path
let path = CGPath(rect: CGRect(origin: .zero, size: suggestedSize), transform: nil)
let frame = CTFramesetterCreateFrame(framesetter, CFRange(location: 0, length: 0), path, nil)
// Get lines and origins
let lines = CTFrameGetLines(frame) as! [CTLine]
var origins = [CGPoint](repeating: .zero, count: lines.count)
CTFrameGetLineOrigins(frame, CFRange(location: 0, length: lines.count), &origins)
// Draw the entire frame
CTFrameDraw(frame, context)
CTTypesetter — Custom Line Breaking
For control over where lines break:
let typesetter = CTTypesetterCreateWithAttributedString(attributedString)
var start: CFIndex = 0
let stringLength = CFAttributedStringGetLength(attributedString)
while start < stringLength {
// Suggest line break for a given width
let count = CTTypesetterSuggestLineBreak(typesetter, start, 300.0)
// Or suggest with cluster breaking (for CJK)
// let count = CTTypesetterSuggestClusterBreak(typesetter, start, 300.0)
let line = CTTypesetterCreateLine(typesetter, CFRange(location: start, length: count))
// Position and draw the line
start += count
}
CTFont — Font Metrics and Features
// Create from UIFont
let uiFont = UIFont.systemFont(ofSize: 16)
let ctFont = CTFontCreateWithName(uiFont.fontName as CFString, uiFont.pointSize, nil)
// Metrics
let ascent = CTFontGetAscent(ctFont)
let descent = CTFontGetDescent(ctFont)
let leading = CTFontGetLeading(ctFont)
let unitsPerEm = CTFontGetUnitsPerEm(ctFont)
// Get glyphs for characters
var characters: [UniChar] = Array("A".utf16)
var glyphs = [CGGlyph](repeating: 0, count: characters.count)
CTFontGetGlyphsForCharacters(ctFont, &characters, &glyphs, characters.count)
// Glyph bounding boxes
var boundingRects = [CGRect](repeating: .zero, count: glyphs.count)
CTFontGetBoundingRectsForGlyphs(ctFont, .default, glyphs, &boundingRects, glyphs.count)
// Glyph path (for custom rendering)
if let path = CTFontCreatePathForGlyph(ctFont, glyphs[0], nil) {
// Draw the glyph outline
context.addPath(path)
context.fillPath()
}
// OpenType features
let features = CTFontCopyFeatures(ctFont) as? [[String: Any]] ?? []
CTRunDelegate — Inline Custom Elements
Reserve space in a line for non-text content (images, custom views):
var callbacks = CTRunDelegateCallbacks(version: kCTRunDelegateCurrentVersion,
dealloc: { _ in },
getAscent: { _ in 20 }, // Height above baseline
getDescent: { _ in 5 }, // Depth below baseline
getWidth: { _ in 30 } // Width of the space
)
let delegate = CTRunDelegateCreate(&callbacks, nil)!
let attrs: [NSAttributedString.Key: Any] = [
kCTRunDelegateAttributeName as NSAttributedString.Key: delegate
]
let placeholder = NSAttributedString(string: "\u{FFFC}", attributes: attrs)
// Insert into your attributed string
// Then draw your custom content at the run's position after layout
Critical: Coordinate System
Core Text uses bottom-left origin (Core Graphics). UIKit uses top-left origin.
Drawing Core Text in UIKit
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
// REQUIRED: Flip coordinate system for Core Text
context.textMatrix = .identity
context.translateBy(x: 0, y: bounds.height)
context.scaleBy(x: 1, y: -1)
// Now draw
CTLineDraw(line, context)
// or CTFrameDraw(frame, context)
}
Forgetting the flip is the #1 Core Text mistake. Text renders upside-down or at the wrong position.
Converting Line Origins from CTFrame
CTFrameGetLineOrigins returns origins in Core Text coordinates (bottom-left). To use in UIKit:
let lines = CTFrameGetLines(frame) as! [CTLine]
var origins = [CGPoint](repeating: .zero, count: lines.count)
CTFrameGetLineOrigins(frame, CFRange(location: 0, length: lines.count), &origins)
for (i, line) in lines.enumerated() {
// Flip y: UIKit y = frameHeight - CoreText y
let uikitY = frameRect.height - origins[i].y
// uikitY is now the BASELINE position in UIKit coordinates
}
Bridging TextKit ↔ Core Text
TextKit 2: Getting Glyph Info from a Layout Fragment
// In a custom NSTextLayoutFragment or delegate callback:
let attributedString = textElement.attributedString
let line = CTLineCreateWithAttributedString(attributedString as CFAttributedString)
let runs = CTLineGetGlyphRuns(line) as! [CTRun]
for run in runs {
let glyphCount = CTRunGetGlyphCount(run)
var positions = [CGPoint](repeating: .zero, count: glyphCount)
CTRunGetPositions(run, CFRange(location: 0, length: glyphCount), &positions)
// positions are relative to the line origin
// Add layoutFragmentFrame.origin to get document coordinates
// Remember to handle the coordinate flip if drawing in UIKit
}
Font Bridging
// macOS: NSFont ↔ CTFont is toll-free bridged
let ctFont = nsFont as CTFont
let nsFont = ctFont as NSFont
// iOS: UIFont ↔ CTFont is NOT toll-free bridged
// UIFont → CTFont
let ctFont = CTFontCreateWithName(uiFont.fontName as CFString, uiFont.pointSize, nil)
// CTFont → UIFont
let uiFont = UIFont(name: CTFontCopyPostScriptName(ctFont) as String,
size: CTFontGetSize(ctFont))!
// NSAttributedString ↔ CFAttributedString IS toll-free bridged (both platforms)
let cfAttrStr = attributedString as CFAttributedString
Attribute Key Differences
Core Text uses its own attribute keys that differ from UIKit/AppKit:
| Purpose | Core Text Key | UIKit/AppKit Key |
|---|---|---|
| Font | kCTFontAttributeName (CTFont) | .font (UIFont/NSFont) |
| Foreground color | kCTForegroundColorAttributeName (CGColor) | .foregroundColor (UIColor/NSColor) |
| Paragraph style | kCTParagraphStyleAttributeName (CTParagraphStyle) | .paragraphStyle (NSParagraphStyle) |
| Kern | kCTKernAttributeName | .kern |
When creating attributed strings for Core Text directly, use the kCT* keys. Mixing Core Text keys and UIKit keys in the same attributed string can cause subtle rendering differences.
In practice, UIKit's .font attribute (UIFont) works with Core Text because UIFont wraps a CTFont internally. But .foregroundColor (UIColor) does NOT — Core Text needs CGColor.
Common Pitfalls
- Forgetting to flip coordinates — Core Text is bottom-left origin. UIKit is top-left. Text appears upside-down or at wrong position. Always set
context.textMatrix = .identityand flip. - Not resetting text matrix —
CGContext.textMatrixpersists between drawing calls. If a previous operation set a non-identity matrix, your Core Text drawing will be transformed unexpectedly. - String indices are UTF-16 —
CTRunGetStringIndicesreturns UTF-16 code unit indices (matching NSString), not Swift Character indices. A single emoji can span 2-4 UTF-16 units. - CTFont ≠ UIFont on iOS — They are NOT toll-free bridged on iOS. Create CTFont explicitly.
- CTFrameGetLines returns non-retained array — In Swift this is usually managed automatically, but be careful with the CFArray if you bridge to C.
- Attribute key mismatch —
kCTForegroundColorAttributeNameexpects CGColor, not UIColor. Passing UIColor silently fails (no color rendered). - Character-glyph mapping is not 1:1 — Ligatures produce fewer glyphs than characters. Complex scripts (Arabic, Devanagari) can produce more glyphs than characters. Always use
CTRunGetStringIndicesfor the mapping. - CTParagraphStyle is not NSParagraphStyle — They are related but not interchangeable. CTParagraphStyle uses a C struct API; NSParagraphStyle has Objective-C properties. NSParagraphStyle internally wraps CTParagraphStyle.
Related Skills
- Use
/skill apple-text-textkit2-reffor the TextKit 2 APIs that sit above Core Text. - Use
/skill apple-text-textkit1-refwhen NSLayoutManager's glyph APIs are sufficient (no need to drop lower). - Use
/skill apple-text-viewport-renderingfor how Core Text fits into the rendering pipeline. - Use
/skill apple-text-attachments-refwhen CTRunDelegate is used for inline non-text content.