Read OSS

丰富布局 API 实战:收缩包裹、障碍物绕排与杂志版面

中级

前置知识

  • 第 1 篇:架构与两阶段模型
  • 第 3 篇:断行引擎(尤其是 layoutNextLine())
  • Canvas 与 SVG 基础概念

丰富布局 API 实战:收缩包裹、障碍物绕排与杂志版面

前四篇文章系统梳理了 Pretext 的内部实现——从两阶段架构、分析 pipeline、断行引擎到浏览器 shim。但一个库的价值,终究体现在它能做什么。本文通过三个示例应用,带你深入 Pretext 富 API 层:基于二分搜索的聊天气泡收缩包裹、具备障碍物感知的杂志版面排版,以及用于文字绕图的 SVG 多边形轮廓提取。这些场景用 DOM 测量方式实现起来都极为困难。

API 层级的区分:不透明路径与段感知路径

正如第 1 篇所述,Pretext 提供两个层级。不透明路径(prepare() + layout())是调整尺寸时的高频热路径——它只返回 { lineCount, height },别无其他。富路径则额外提供四项关键能力:

  1. prepareWithSegments() 暴露段文本及双向文本层级
  2. layoutWithLines() 生成携带文本内容及每行宽度的 LayoutLine[]
  3. walkLineRanges() 在不物化字符串的情况下提供行几何信息
  4. layoutNextLine() 支持迭代器风格的可变宽度布局
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

两个层级之所以并存,是因为它们服务于不同的性能需求。不透明路径在 layout() 执行期间零内存分配——没有对象、没有字符串、没有数组。富路径则必须分配行对象,并可能需要物化文本,这在渲染时是合理的,但对于"这个宽度能排几行?"这类推测性查询来说开销过高。

使用 walkLineRanges() 与二分搜索实现气泡收缩包裹

bubbles 示例解决了一个常见的 UI 痛点:聊天消息气泡往往比实际需要的更宽。当消息在容器最大宽度处换行后,最后一行通常短得多,造成大量空白浪费。

pages/demos/bubbles-shared.ts#L49-L77

收缩包裹算法的目标是找到能保持相同行数的最小容器宽度:

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)

这套方案之所以可行,是因为 layout() 极其快速(约 0.0002ms),在二分搜索中调用约 10 次完全可以忽略不计。collectWrapMetrics() 使用 walkLineRanges() 获取指定容器宽度下各行的实际最大宽度——这是一个不物化字符串、只计算行宽的几何遍历过程:

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

layout.ts 中的 walkLineRanges() 底层调用的是与 layout() 相同的 walkPreparedLines() 引擎,但会为每一行的几何信息触发一个 onLine 回调:

src/layout.ts#L669-L679

提示: 收缩包裹不需要字符串物化——walkLineRanges() 能在不分配行文本的情况下提供每行宽度和游标。只有当你真正需要文本内容用于渲染时,才应使用 layoutWithLines()

使用 layoutNextLine() 实现可变宽度文字流排

dynamic-layout 示例展示了一个杂志双栏版面,正文文字需要绕过可旋转的 SVG logo 进行排版。这要求可变宽度布局——每一行的可用宽度取决于该行所在竖向区间内有哪些障碍物占据了空间。

pages/demos/dynamic-layout.ts#L1-L24

整体流程如下:

  1. 对正文文本执行一次 prepare
  2. 对每一行,通过从栏宽中减去障碍物区间来计算可用宽度
  3. 以该宽度调用 layoutNextLine()
  4. 将返回的结束游标作为下一行的起始位置
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

左栏先消费文本,右栏从同一游标继续——不存在重复处理或重新分段。layout.ts 中的 layoutNextLine() API 封装了所有底层细节:

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

在内部,这被拆分为两个操作:stepPreparedLineRange()(遍历一行并返回游标)和 materializeLine()(从段数据构建文本字符串)。正如第 3 篇所述,游标规范化会跳过行首空白字符,并在硬换行处处理块边界。

使用 layoutWithLines() 批量物化

对于所有行共享同一宽度的简单场景,layoutWithLines() 提供批量行物化能力:

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

buildLineTextFromRange() 负责处理行文本物化中最棘手的部分,包括可选连字符的插入:

src/layout.ts#L542-L579

当一行在软连字符处断行时,该函数会在物化文本中插入字面量 -。这是行遍历器的断行决策唯一会在最终文本中产生原始内容中不存在的可见字符的地方。

字形缓存(grapheme cache)同样值得一提。getLineTextCache() 返回一个 Map<number, string[]>,以 WeakMap 存储,键为 prepared 句柄:

src/layout.ts#L70

let sharedLineTextCaches = new WeakMap<PreparedTextWithSegments, Map<number, string[]>>()

WeakMap 的使用确保了当 PreparedTextWithSegments 句柄被垃圾回收时,其对应的字形缓存会随之释放——无需手动清理。

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 }"]

绕排几何:从 SVG Alpha 通道到多边形轮廓

视觉上最引人注目的功能是文字绕 SVG logo 排版。wrap-geometry.ts 模块实现了将 SVG 图像转换为适合障碍物规避的多边形轮廓的完整 pipeline:

pages/demos/wrap-geometry.ts#L157-L216

处理流程如下:

  1. 加载并解码 SVG 图像
  2. 以合适的分辨率(最大 320px)将其光栅化到 OffscreenCanvas
  3. 通过 getImageData() 提取 alpha 通道
  4. 对每一行,找到最左和最右的不透明像素
  5. 计算边界框并将坐标归一化到 0–1 范围
  6. 可选地用滚动窗口对轮廓进行平滑处理
  7. 构建最终的多边形轮廓 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]

轮廓随后在渲染时通过 transformWrapPoints() 进行变换,将归一化坐标映射到屏幕坐标并应用旋转:

pages/demos/wrap-geometry.ts#L37-L58

carveTextLineSlots() 函数接收完整宽度的行区间,减去被障碍物占用的区间,返回剩余可用的文字排版槽位:

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
}

24px 的最小宽度过滤是一个务实的选择——宽度不足约两个字符的排版槽位只会产生糟糕的排版效果,徒增布局开销。

提示: 多边形轮廓的计算对每个 SVG 只执行一次,并缓存在 Map<string, Promise<Point[]>> 中。旋转和缩放在渲染时通过仿射变换完成,确保用户拖拽或旋转时障碍物重新计算的开销极低。

展望下一篇

至此,我们已经完整见识了富 API 层的实战能力——从优雅简洁的二分搜索收缩包裹,到复杂的障碍物绕排杂志版面。这些示例清楚地表明,Pretext 的两阶段架构不仅让尺寸调整变得高效,更开创了全新的文字排版可能性,而这些在同步 DOM 测量方案下几乎无从实现。

第 6 篇也是最后一篇,我们将深入 Pretext 的测试与验证体系——正是这套体系保障了 Pretext 在三大浏览器和十余种世界文字下的准确性:确定性的 fake canvas 测试、自动化的浏览器精度扫描、多语言语料库验证,以及作为知识沉淀的 RESEARCH.md。