Skip to content

tornikegomareli/swift-pretextkit

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

64 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

PretextKit

Measure text once. Reflow forever.

Swift 5.9+ iOS 16+ macOS 13+ SPM License: MIT

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.

Install

// Package.swift
.package(url: "https://github.com/tornikegomareli/swift-pretextkit.git", from: "0.1.0")

API

PretextKit serves two use cases, same as the original:

1. Measure text height without CoreText in the loop

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  // 2

prepare() 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))

2. Lay out lines yourself

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 lines

Flow 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
}

API summary

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

UIKit

Drop-in label

let label = PretextLabel()
label.font = .systemFont(ofSize: 16)
label.lineHeight = 22
label.text = "Hello, world!"
// sizeThatFits is now pure arithmetic β€” no CoreText in the loop
let prepared = prepareWithSegments(text, font: FontDescriptor(.systemFont(ofSize: 16)))
label.preparedText = prepared  // Skips re-preparation

Collection view cell sizing

class 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)
    }
}

SwiftUI

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)

Font setup

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)

International text

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)

Cache management

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"))

When to use PretextKit

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.

Caveats

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.


What changed in the Swift port

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.


Demos

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

Acknowledgment

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.

License

MIT

About

Swift port of chenglou/pretext, arithmetic text measurement for Apple platforms

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages