Rich Layout APIs in Action: Shrinkwrap, Obstacle Routing, and Editorial Spreads
Prerequisites
- ›Article 1: Architecture and the Two-Phase Model
- ›Article 3: Line Breaking Engine (especially layoutNextLine())
- ›Basic Canvas and SVG concepts
Rich Layout APIs in Action: Shrinkwrap, Obstacle Routing, and Editorial Spreads
The previous four articles traced Pretext's internals — from the two-phase architecture through the analysis pipeline and line-breaking engine to browser shims. But a library's value ultimately lives in what it enables. This article explores the rich tier of Pretext's API through three demo applications that would be impractical with DOM-based measurement: chat bubble shrinkwrap via binary search, obstacle-aware editorial layout, and SVG polygon hull extraction for text flow around arbitrary shapes.
API Tier Distinction: Opaque vs Segment-Aware
As we established in Part 1, Pretext offers two tiers. The opaque path (prepare() + layout()) is the resize hot path — it returns { lineCount, height } and nothing else. The rich path adds four key capabilities:
prepareWithSegments()exposes segment text and bidi levelslayoutWithLines()materializesLayoutLine[]with text content and per-line widthswalkLineRanges()provides line geometry without materializing stringslayoutNextLine()enables iterator-style variable-width layout
flowchart LR
subgraph "Opaque Path"
PP[prepare] --> LL[layout]
LL --> LC["lineCount + height"]
end
subgraph "Rich Path"
PWS[prepareWithSegments] --> LWL[layoutWithLines]
PWS --> WLR[walkLineRanges]
PWS --> LNL[layoutNextLine]
LWL --> Lines["LayoutLine[] with text"]
WLR --> Ranges["LayoutLineRange[] no text"]
LNL --> OneLine["One LayoutLine at a time"]
end
The two tiers exist because they serve different performance profiles. The opaque path allocates nothing during layout() — no objects, no strings, no arrays. The rich path must allocate line objects and possibly materialize text, which is appropriate for rendering but too expensive for speculative layout queries like "how many lines at this width?"
Bubble Shrinkwrap with walkLineRanges() and Binary Search
The bubbles demo solves a common UI problem: chat message bubbles that are wider than they need to be. When a message wraps at the container's max width, the last line is often much shorter, leaving wasted horizontal space.
pages/demos/bubbles-shared.ts#L49-L77
The shrinkwrap algorithm finds the minimum container width that produces the same line count as the max width:
export function findTightWrapMetrics(prepared, maxWidth) {
const initial = collectWrapMetrics(prepared, maxWidth)
let lo = 1
let hi = Math.max(1, Math.ceil(maxWidth))
while (lo < hi) {
const mid = Math.floor((lo + hi) / 2)
const midLineCount = layout(prepared, mid, LINE_HEIGHT).lineCount
if (midLineCount <= initial.lineCount) {
hi = mid
} else {
lo = mid + 1
}
}
return collectWrapMetrics(prepared, lo)
}
sequenceDiagram
participant App as Bubble Renderer
participant Layout as layout()
participant Walk as walkLineRanges()
App->>Walk: collectWrapMetrics(prepared, maxWidth)
Walk-->>App: {lineCount: 3, maxLineWidth: 342}
loop Binary Search
App->>Layout: layout(prepared, mid, LINE_HEIGHT)
Layout-->>App: {lineCount: N}
Note over App: if N <= 3: hi = mid<br/>else: lo = mid + 1
end
App->>Walk: collectWrapMetrics(prepared, lo)
Walk-->>App: {lineCount: 3, maxLineWidth: 289}
Note over App: Tight width = 289 (saved 53px)
This works because layout() is so fast (~0.0002ms) that running it ~10 times in a binary search is negligible. The collectWrapMetrics() function uses walkLineRanges() to find the actual maximum line width at a given container width — this is the non-materializing geometry pass that computes line widths without building string objects:
pages/demos/bubbles-shared.ts#L49-L59
export function collectWrapMetrics(prepared, maxWidth) {
let maxLineWidth = 0
const lineCount = walkLineRanges(prepared, maxWidth, line => {
if (line.width > maxLineWidth) maxLineWidth = line.width
})
return { lineCount, height: lineCount * LINE_HEIGHT, maxLineWidth }
}
The walkLineRanges() function in layout.ts delegates to the same walkPreparedLines() engine used by layout(), but invokes an onLine callback with each line's geometry:
Tip: For shrinkwrap, you don't need string materialization —
walkLineRanges()gives you per-line widths and cursors without allocating line text. UselayoutWithLines()only when you need the actual text content for rendering.
Variable-Width Flow with layoutNextLine()
The dynamic-layout demo shows an editorial magazine spread where body text flows around rotatable SVG logos across two columns. This requires variable-width layout — each line's available width depends on which obstacles occupy that vertical band.
pages/demos/dynamic-layout.ts#L1-L24
The pattern is:
- Prepare the body text once
- For each line, compute the available width by subtracting obstacle intervals from the column width
- Call
layoutNextLine()with that width - Pass the returned end cursor as the start of the next line
sequenceDiagram
participant App as Editorial Layout
participant Geo as Wrap Geometry
participant LNL as layoutNextLine()
Note over App: Prepared body text once
loop For each line slot
App->>Geo: carveTextLineSlots(column, obstacles)
Geo-->>App: Available intervals [80..290, 340..500]
App->>LNL: layoutNextLine(prepared, cursor, 210)
LNL-->>App: {text: "line content", end: cursor2}
App->>LNL: layoutNextLine(prepared, cursor2, 160)
LNL-->>App: {text: "more content", end: cursor3}
end
The left column consumes text first, and the right column resumes from the same cursor — there's no duplication or re-segmentation. The layoutNextLine() API from layout.ts handles all the mechanics:
export function layoutNextLine(prepared, start, maxWidth) {
const line = stepLineRange(prepared, start, maxWidth)
if (line === null) return null
return materializeLine(prepared, line)
}
Internally, this splits into two operations: stepPreparedLineRange() (which walks one line and returns cursors) and materializeLine() (which builds the text string from segment data). As we discussed in Part 3, the cursor normalization skips leading whitespace and handles chunk boundaries for hard breaks.
Batch Materialization with layoutWithLines()
For simpler cases where all lines share the same width, layoutWithLines() provides batch line materialization:
export function layoutWithLines(prepared, maxWidth, lineHeight) {
const lines = []
const graphemeCache = getLineTextCache(prepared)
const lineCount = walkPreparedLines(getInternalPrepared(prepared), maxWidth, line => {
lines.push(materializeLayoutLine(prepared, graphemeCache, line))
})
return { lineCount, height: lineCount * lineHeight, lines }
}
The buildLineTextFromRange() function handles the tricky work of materializing line text, including discretionary hyphen insertion:
When a line ends at a soft-hyphen break, the function inserts a literal - into the materialized text. This is the only place where the line walker's break decisions create visible content that wasn't in the original text.
The grapheme cache deserves mention. The getLineTextCache() function returns a Map<number, string[]> stored in a WeakMap keyed by the prepared handle:
let sharedLineTextCaches = new WeakMap<PreparedTextWithSegments, Map<number, string[]>>()
The WeakMap ensures that when a PreparedTextWithSegments handle is garbage collected, its grapheme cache goes with it — no manual cleanup required.
flowchart TD
A[layoutWithLines call] --> B[walkPreparedLines]
B --> C[onLine callback per line]
C --> D[materializeLayoutLine]
D --> E[buildLineTextFromRange]
E --> F{Line ends with soft-hyphen?}
F -->|yes| G["Insert '-' in text"]
F -->|no| H[Concatenate segment text]
G --> I[Create LayoutLine]
H --> I
I --> J[Push to lines array]
J --> B
B --> K["Return { lineCount, height, lines }"]
Wrap Geometry: SVG Alpha to Polygon Hulls
The most visually striking demo feature is text flowing around SVG logos. The wrap-geometry.ts module implements the pipeline that converts an SVG image into a polygon hull suitable for obstacle avoidance:
pages/demos/wrap-geometry.ts#L157-L216
The process:
- Load and decode the SVG image
- Rasterize it to an
OffscreenCanvasat a manageable resolution (max 320px) - Extract the alpha channel from
getImageData() - For each row, find the leftmost and rightmost opaque pixels
- Compute a bounding box and normalize coordinates to 0–1
- Optionally smooth the contour with a rolling window
- Build the final polygon hull as
Point[]
flowchart TD
A[SVG URL] --> B[Image decode]
B --> C[Rasterize to OffscreenCanvas]
C --> D[getImageData — extract alpha]
D --> E[Per-row: find leftmost/rightmost opaque pixel]
E --> F[Normalize to 0..1 coordinates]
F --> G[Optional smoothing]
G --> H["Point[] polygon hull"]
H --> I[Cache by src + options]
The hull is then transformed at render time using transformWrapPoints(), which maps normalized coordinates to screen coordinates and applies rotation:
pages/demos/wrap-geometry.ts#L37-L58
The carveTextLineSlots() function takes a full-width line interval and subtracts blocked obstacle intervals, returning the remaining usable text slots:
pages/demos/wrap-geometry.ts#L136-L155
export function carveTextLineSlots(base, blocked) {
let slots = [base]
for (const interval of blocked) {
const next = []
for (const slot of slots) {
if (interval.right <= slot.left || interval.left >= slot.right) {
next.push(slot)
continue
}
if (interval.left > slot.left) next.push({ left: slot.left, right: interval.left })
if (interval.right < slot.right) next.push({ left: interval.right, right: slot.right })
}
slots = next
}
return slots.filter(slot => slot.right - slot.left >= 24) // discard tiny slivers
}
The 24px minimum-width filter is pragmatic — text slots narrower than about two characters would produce ugly results and waste layout effort.
Tip: The polygon hull computation happens once per SVG and is cached in a
Map<string, Promise<Point[]>>. Rotation and scaling are applied at render time via affine transforms, keeping obstacle recalculation cheap during live drag/rotate interactions.
Looking Ahead
We've now seen the rich API tier in action — from the simple elegance of binary-search shrinkwrap to the complexity of obstacle-routed editorial layout. These demos demonstrate that Pretext's two-phase architecture doesn't just make resize cheap; it enables entirely new categories of text layout that would be impractical with synchronous DOM measurement.
In Part 6, the final article, we'll examine the testing and validation infrastructure that gives Pretext confidence in its accuracy across three browsers and a dozen world scripts: deterministic fake canvas tests, automated browser accuracy sweeps, multilingual corpus validation, and RESEARCH.md as institutional memory.