Measure text once. Reflow forever.
Swift port of Cheng Lou's Pretext for Apple platforms.
CoreText measurement once, just arithmetic on every resize.
bouncing-orbs-compressed.mp4
When you call NSAttributedString.boundingRect or UILabel.sizeThatFits with a different width, the full CoreText pipeline runs again, shaping, line breaking, all of it. That's fine for a single measurement, but adds up when you're sizing hundreds of cells or reflowing text every frame.
PretextKit runs CoreText once in prepare() to measure and cache segment widths. After that, layout() figures out where lines break at any new width using just arithmetic over those cached widths.
Based on Cheng Lou's insight that text shaping and line breaking are two separate problems and only shaping is expensive.
// Package.swift
.package(url: "https://github.com/tornikegomareli/swift-pretextkit.git", from: "0.1.0")PretextKit serves two use cases, same as the original:
import PretextKit
let font = FontDescriptor(.systemFont(ofSize: 16))
// One-time: segment, measure, cache
let prepared = prepare("AGI ζ₯倩ε°δΊ. Ψ¨Ψ―Ψ£Ψͺ Ψ§ΩΨ±ΨΩΨ© π", font: font)
// On every resize β just arithmetic, no CoreText
let result = layout(prepared, maxWidth: containerWidth, lineHeight: 22)
result.height // 44.0
result.lineCount // 2prepare() is the expensive call, run it once per (text, font) pair. layout() is the cheap call, run it on every resize, rotation, or reflow. Don't re-run prepare() when only the width changes.
For textarea-like text where spaces, tabs, and newlines stay visible:
let prepared = prepare(text, font: font, options: PrepareOptions(whiteSpace: .preWrap))Switch prepare to prepareWithSegments, then pick the API that fits:
Get all lines at a fixed width:
let prepared = prepareWithSegments(text, font: font)
let result = layoutWithLines(prepared, maxWidth: 320, lineHeight: 26)
for (i, line) in result.lines.enumerated() {
let attrStr = NSAttributedString(string: line.text, attributes: [.font: uiFont])
let ctLine = CTLineCreateWithAttributedString(attrStr as CFAttributedString)
context.textPosition = CGPoint(x: 0, y: CGFloat(i) * 26)
CTLineDraw(ctLine, context)
}Find the tightest container:
var maxLineWidth: CGFloat = 0
walkLineRanges(prepared, maxWidth: 320) { line in
maxLineWidth = max(maxLineWidth, line.width)
}
// maxLineWidth is now the minimum width that still fits all linesFlow text around obstacles
var cursor = LayoutCursor.start
var y: CGFloat = 0
while let line = layoutNextLine(prepared, start: cursor, maxWidth: widthAtY(y)) {
let attrStr = NSAttributedString(string: line.text, attributes: [.font: uiFont])
let ctLine = CTLineCreateWithAttributedString(attrStr as CFAttributedString)
context.textPosition = CGPoint(x: 0, y: y)
CTLineDraw(ctLine, context)
cursor = line.end
y += 26
}| Function | Input | Output | Cost |
|---|---|---|---|
prepare(text, font) |
String + font | PreparedText |
~0.04ms per text (CoreText) |
layout(prepared, maxWidth, lineHeight) |
PreparedText + width | height, lineCount | ~0.0001ms (arithmetic) |
prepareWithSegments(text, font) |
String + font | PreparedTextWithSegments |
Same as prepare() |
layoutWithLines(prepared, maxWidth, lineHeight) |
Segments + width | lines with text/width | Arithmetic + string build |
walkLineRanges(prepared, maxWidth, onLine) |
Segments + width | line widths/cursors | Arithmetic only |
layoutNextLine(prepared, start, maxWidth) |
Segments + cursor + width | single line or nil | Arithmetic + string build |
clearCache() |
β | β | Frees measurement cache |
setLocale(locale) |
Locale? | β | Retargets word segmenter |
let label = PretextLabel()
label.font = .systemFont(ofSize: 16)
label.lineHeight = 22
label.text = "Hello, world!"
// sizeThatFits is now pure arithmetic β no CoreText in the looplet prepared = prepareWithSegments(text, font: FontDescriptor(.systemFont(ofSize: 16)))
label.preparedText = prepared // Skips re-preparationclass MyDataSource: UICollectionViewDataSource, UICollectionViewDataSourcePrefetching {
let sizingCache = PretextSizingCache()
let font = FontDescriptor(.systemFont(ofSize: 16))
func collectionView(_ cv: UICollectionView, prefetchItemsAt ips: [IndexPath]) {
for ip in ips {
sizingCache.prepare(items[ip.item].text, font: font, identifier: ip)
}
}
func collectionView(_ cv: UICollectionView, layout: UICollectionViewLayout,
sizeForItemAt ip: IndexPath) -> CGSize {
let h = sizingCache.height(for: ip, width: cellWidth, lineHeight: 22) ?? 44
return CGSize(width: cellWidth, height: h)
}
func collectionView(_ cv: UICollectionView, cancelPrefetchingForItemsAt ips: [IndexPath]) {
sizingCache.cancel(identifiers: ips)
}
}Auto-sized text (measures available width via GeometryReader, computes exact height):
let ctFont = CTFontCreateWithName("Georgia" as CFString, 16, nil)
let prepared = prepareWithSegments(text, font: FontDescriptor(ctFont))
PretextAutoSizedText(prepared: prepared, font: ctFont, lineHeight: 24)Parent-constrained text (when the parent already provides a fixed size):
PretextText(prepared: prepared, font: ctFont, lineHeight: 24, textColor: .secondary)
.frame(width: 300, height: 200)Size any view based on text layout:
Color.clear
.pretextFrame(prepared: myPrepared, lineHeight: 22)On iOS/tvOS, wrap a UIFont:
let font = FontDescriptor(.systemFont(ofSize: 16))
let font = FontDescriptor(.preferredFont(forTextStyle: .body))On macOS (or cross-platform code), use CoreText directly:
let ctFont = CTFontCreateWithName("Helvetica Neue" as CFString, 16, nil)
let font = FontDescriptor(ctFont)CJK per-character breaking with kinsoku rules, Arabic RTL, Thai dictionary-based segmentation, emoji ZWJ sequences (π¨βπ©βπ§βπ¦ stays intact), soft hyphens, NBSP glue, mixed-script paragraphs.
For text in languages that need locale context for word segmentation (Thai, Lao, Khmer, Myanmar):
setLocale(Locale(identifier: "th"))
let prepared = prepare(thaiText, font: font)
// Reset to system default when done
setLocale(nil)The measurement cache is global and shared across all prepare() calls. Normally you never need to touch it, but:
// Free memory when fonts change dynamically or on memory warning
clearCache()
// setLocale() also clears the cache automatically
setLocale(Locale(identifier: "ja"))Good fit: Virtual scrolling, collection/table view cell sizing, masonry layouts, text flowing around obstacles, any UI where the same text is re-measured at many widths (rotation, resize, reflow).
Overkill: A single static label that renders once, or text that changes every frame (you'd re-run prepare() each time, losing the benefit).
Not supported: Attributed strings with mixed fonts/sizes within a single text, rich text with inline images, or CSS modes beyond normal and pre-wrap.
Targets the common text configuration: word wrapping with break-word overflow (same as the original Pretext's white-space: normal, word-break: normal, overflow-wrap: break-word). Pre-wrap mode available for preserved whitespace.
The core algorithm is a 1:1 port from the original TypeScript. Changes are platform-specific:
| Area | Original (TS) | Swift port |
|---|---|---|
| Measurement | canvas.measureText() |
CTLine + CTLineGetTypographicBounds |
| Segmentation | Intl.Segmenter |
CFStringTokenizer |
| Thread safety | Single-threaded | OSAllocatedUnfairLock on shared cache |
| Engine profiles | Per-browser epsilon | Single CoreText epsilon (0.005) |
| Emoji correction | Canvas/DOM mismatch fix | Not needed (CoreText consistent) |
| Hot path | Array indexing | withUnsafeBufferPointer |
Not yet ported: bidi rendering metadata (segLevels) and URL-specific segmentation rules.
Breaker β Text reflows around ball, paddle, and word-bricks
tetris-compressed.mp4
Tetris β Falling tetrominoes swim in reflowing text
shrinkwrip-compressed.mp4
Bouncing Orbs β 60fps physics with text flowing around obstacles
dynamiclayout-compressed.mp4
Shrinkwrap β Standard sizing vs binary-search minimum width
bracket-compressed.mp4
Speed β Full benchmark showcase
speed-compressed.mp4
Swift port of Pretext by Cheng Lou, building on Sebastian Markbage's text-layout. The two-phase architecture, segment model, merge rules, Unicode tables, and line-breaking algorithm are Cheng Lou's work. PretextKit adds CoreText measurement, UIKit/SwiftUI integration, and thread-safe caching for Apple platforms.
MIT