Pretext Lab
25Shape-aware width
Arc 9 · Kinetic & Organic

Shape-aware width

The last lesson of Arc 6 showed text re-routing around a rectangle. Real editorial layouts almost never have rectangles; they have silhouettes — a bird in flight, a cut-out portrait, a curled flourish of ink. The mechanism is the same. The shape predicate is not.

Below: a paragraph of Zhuangzi hugs a bird that drifts across the page on a slow figure-eight. Where the bird is wide, the text is narrow. Where the bird is narrow, the text widens. Every line's width is computed, fresh, from the bird's silhouette at that line's y.

text hugs the silhouette · bird drifts on a figure-8
42
100

Mechanism

At startup we sample the SVG path's horizontal profile. For each y in the shape's canonical coordinate space, we ask: what is the leftmost and rightmost x the shape occupies here? SVGGeometryElement.isPointInFill() is the tool — walk x values at a given y, find the fill's left and right edges, cache them as { left, right } tuples in a dense array keyed by y. One pass, done.

Each animation frame we update the shape's translate transform along a figure-8 parametric curve. Then, for each line of text, we convert the line's baseline y from text coordinates to shape coordinates by subtracting the current translate. We look up the profile at that shape-y, translate it back into text-column x's, and compute the available width: the column's right minus the shape's right, or the shape's left minus the column's left — whichever gives text-safe space on the flowing side.

That number is what we pass to layoutNextLine(prepared, cursor, maxWidth). The returned LayoutLine holds the line text Pretext would have chosen at that exact width. Advance the cursor to line.end, step y forward by line height, ask for the next width, ask for the next line. When layoutNextLine returns null the paragraph is done.

Application

Editorial layouts the web has never been good at:

The CSS shape-outside property can do some of this for static images, but it can't react to drift, drag, or scroll velocity. The shape-predicate loop can.

“The wings of Peng, the great bird, were like clouds hanging from the sky. When the sea moved, he rose on the whirlwind and flew ninety thousand li to the south.”

Zhuangzi (c. 4th century BCE), tr. James Legge (1891)

Direct Claude

“text that wraps around the shape” per-line maxWidth from shapeProfile(y); one layoutNextLine per row “hug the silhouette, not a bounding box” isPointInFill sweep at startup; lookup table by y-row “shape moves, text re-routes” translate profile by current shape-y each frame; relayout “organic flow” sample every ~4 px of y; smooth width changes between lines
Next: taking the same prepare handle and cutting it into book pages.
shape profile → per-line width → layoutNextLine
import { prepareWithSegments, layoutNextLine } from '@chenglou/pretext';

// Phase 0: one-time shape profile. For each y in the shape's local space,
// find leftmost and rightmost x the fill occupies.
function sampleShapeProfile(pathEl, yMin, yMax, step = 2) {
  const svgPt = pathEl.ownerSVGElement.createSVGPoint();
  const profile = [];
  for (let y = yMin; y <= yMax; y += step) {
    let left = null, right = null;
    for (let x = -120; x <= 120; x += 2) {
      svgPt.x = x; svgPt.y = y;
      if (pathEl.isPointInFill(svgPt)) {
        if (left === null) left = x;
        right = x;
      }
    }
    profile.push({ y, left, right });  // null/null means "no shape here"
  }
  return profile;
}

// Phase 1 + 2: per-frame relayout with per-line width.
function relayout(prepared, profile, shapeCenterX, shapeCenterY, columnW) {
  let cursor = { segmentIndex: 0, graphemeIndex: 0 };
  let y = 0;
  const lines = [];

  while (true) {
    // Convert text-space y to shape-space y, then look up the profile row.
    const shapeY = (y + LINE_HEIGHT / 2) - shapeCenterY;
    const row = profile.find(r => Math.abs(r.y - shapeY) < PROFILE_STEP);

    let width = columnW;
    if (row && row.left !== null) {
      // Shape's rightmost x in text coords:
      const shapeRightInText = shapeCenterX + row.right;
      const shapeLeftInText  = shapeCenterX + row.left;
      // Flow text on whichever side leaves more room.
      const leftRoom  = shapeLeftInText;
      const rightRoom = columnW - shapeRightInText;
      width = Math.max(60, Math.max(leftRoom, rightRoom));
    }

    const line = layoutNextLine(prepared, cursor, width);
    if (line === null) break;
    lines.push({ text: line.text, y, width });
    cursor = line.end;
    y += LINE_HEIGHT;
  }
  return lines;
}

Inspired by Azamat Takmalayev's Dragon + Anime Walk demos and Cheng Lou's umbrella-flow demo at chenglou.me/pretext.