Read OSS

Rich Layout APIs in Action: Shrinkwrap, Obstacle Routing, and Editorial Spreads

Intermediate

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:

  1. prepareWithSegments() exposes segment text and bidi levels
  2. layoutWithLines() materializes LayoutLine[] with text content and per-line widths
  3. walkLineRanges() provides line geometry without materializing strings
  4. layoutNextLine() 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?"

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:

src/layout.ts#L669-L679

Tip: For shrinkwrap, you don't need string materialization — walkLineRanges() gives you per-line widths and cursors without allocating line text. Use layoutWithLines() 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:

  1. Prepare the body text once
  2. For each line, compute the available width by subtracting obstacle intervals from the column width
  3. Call layoutNextLine() with that width
  4. 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:

src/layout.ts#L681-L689

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:

src/layout.ts#L695-L705

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:

src/layout.ts#L542-L579

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:

src/layout.ts#L70

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:

  1. Load and decode the SVG image
  2. Rasterize it to an OffscreenCanvas at a manageable resolution (max 320px)
  3. Extract the alpha channel from getImageData()
  4. For each row, find the leftmost and rightmost opaque pixels
  5. Compute a bounding box and normalize coordinates to 0–1
  6. Optionally smooth the contour with a rolling window
  7. 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.