Read OSS

换行引擎:快速路径算术与浏览器行为一致性

高级

前置知识

  • 第 1 篇:架构与两阶段模型
  • 第 2 篇:文本分析流水线
  • CSS 换行行为(overflow-wrap、行尾空白)

换行引擎:快速路径算术与浏览器行为一致性

前两篇文章介绍了 Pretext 的"慢速"部分——prepare() 一次性完成的分析与测量工作。现在我们进入"快速"部分:line-break.ts 中的换行引擎,它在每次调用 layout() 时执行。正是这段代码实现了每个文本块约 0.0002ms 的处理速度,其设计思路始终围绕一个核心:让热路径尽可能精简。

该引擎实现了 CSS white-space: normal + overflow-wrap: break-word 的换行语义:在任何会溢出的非空格片段前换行,让行尾空白悬挂在行边界之外,并在单词宽度超过容器时回退到字形级别的断行。同时,它还完整处理了软连字符、制表符、强制换行以及各浏览器之间的行为差异。

Simple 与 Full 路径的分发

热路径的入口是 countPreparedLines(),它根据 prepare 阶段设置的标志位来决定调用哪个 walker:

src/line-break.ts#L166-L171

export function countPreparedLines(prepared, maxWidth) {
  if (prepared.simpleLineWalkFastPath) {
    return countPreparedLinesSimple(prepared, maxWidth)
  }
  return walkPreparedLines(prepared, maxWidth)
}

当文本只包含 textspacezero-width-break 片段——不含强制换行、软连字符、制表符、glue 或保留空格——simpleLineWalkFastPath 标志就为 true。正如第 2 篇所介绍的,只要在测量阶段遇到非 simple 类型的片段,这个标志就会被置为 false

flowchart TD
    A["countPreparedLines(prepared, maxWidth)"] --> B{simpleLineWalkFastPath?}
    B -->|true| C["walkPreparedLinesSimple()<br/>Handles: text, space, ZWSP"]
    B -->|false| D["walkPreparedLines()<br/>Handles: all 8 segment kinds"]
    C --> E[Return line count]
    D --> E

这个区分的意义远不止于此。simple walker 完全跳过了 chunk 迭代、软连字符逻辑、制表符位计算,以及 lineEndFitAdvancelineEndPaintAdvance 的区别处理。对于典型应用中的大多数文本——英文段落、聊天消息、社交帖子——走的都是 simple 路径。

Simple 换行 Walker

walkPreparedLinesSimple() 是核心热路径循环。我们来逐步梳理它的逻辑:

src/line-break.ts#L177-L351

walker 维护了一组精简的可变状态变量,全部是基本类型的数字和布尔值:

let lineCount = 0
let lineW = 0                    // Accumulated width of current line
let hasContent = false            // Whether the current line has any content
let pendingBreakSegmentIndex = -1 // Where to break if overflow occurs
let pendingBreakPaintWidth = 0    // Width to report for the pending break point

主循环遍历片段索引,对每个片段处理三种情况:

情况一:开始新行。 跳过行首空格和零宽断点,从第一个非空格片段开始新行。如果该片段的宽度超过 maxWidth 且带有 breakableWidths,则在字形级别进行断行。

情况二:片段适配。 将片段宽度累加到 lineW,推进末尾游标;若该片段是可断点(空格或 ZWSP),则将其记录为 pendingBreak

情况三:片段溢出。 这里是核心逻辑所在,walker 需要判断在哪里断行:

stateDiagram-v2
    state "Segment overflows (newW > maxWidth + ε)" as Overflow
    state "Is current segment breakable?" as CanBreak
    state "Pending break exists?" as HasPending
    state "Segment wider than maxWidth with grapheme widths?" as WideBreakable

    Overflow --> CanBreak
    CanBreak --> EmitWithTrailing: yes (space/ZWSP)
    CanBreak --> HasPending: no
    HasPending --> EmitAtPending: yes
    HasPending --> WideBreakable: no
    WideBreakable --> GraphemeBreak: yes
    WideBreakable --> EmitBeforeCurrent: no

    EmitWithTrailing: Append segment, emit line\nwithout trailing space width
    EmitAtPending: Emit line at pending break point
    GraphemeBreak: Emit current line, break\nsegment at grapheme boundaries
    EmitBeforeCurrent: Emit line, retry\ncurrent segment on next line

行尾空白的处理方式微妙而符合 CSS 规范:当一个空格片段触发溢出时,它会被追加到当前行,但输出的行宽不包含该空格的宽度。这就是"悬挂空白"行为——行尾空格悬挂在行边界之外,不触发断行。

pending break 机制用于处理多个文本片段在上一个断点之后集体溢出的情况。如果在片段 ilineW 超过了 maxWidth,但片段 j < i 处有一个空格作为最近的断点,walker 就会在片段 j 处输出当前行,并从 j+1 重新开始处理。

Full 换行 Walker:Chunk、软连字符与制表符

simpleLineWalkFastPathfalse 时,完整版的 walkPreparedLines() 接管处理。它的核心结构相同,但新增了三项主要能力:

src/line-break.ts#L353-L648

基于 chunk 的迭代:强制换行符在分析阶段将文本划分为若干 chunk。full walker 在外层循环中遍历 chunk,在内层循环中遍历每个 chunk 内的片段。连续两个强制换行形成的空 chunk 会立即输出一个空行。

src/line-break.ts#L539-L544

软连字符逻辑:软连字符默认不可见,但会创建断行机会。遇到软连字符时,walker 会记录一个 pending break,其宽度包含 discretionaryHyphenWidth(即 - 的宽度)。当发生溢出且 pending break 是软连字符时,walker 会首先尝试 continueSoftHyphenBreakableSegment()——尽可能多地将下一个单词的字形放入当前行,直到加上连字符和剩余字形后会溢出为止。Safari 还有进一步的特殊处理:preferEarlySoftHyphenBreak 标志让它倾向于提前在软连字符处断行,而不是尽量多放字形。

src/line-break.ts#L489-L525

制表符位:tab 片段的宽度是动态计算的,由 getTabAdvance() 根据当前行位置计算到下一个制表符位的距离。制表符位以 8 × spaceWidth 为间隔均匀分布。

src/line-break.ts#L61-L67

Fit-Advance 与 Paint-Advance:微妙的宽度区分

引擎中最精妙的设计之一,是将 lineEndFitAdvancelineEndPaintAdvance 分开处理。每个片段同时携带这两个值,含义各不相同:

lineEndFitAdvance:用于判断行是否溢出的宽度贡献。在决定当前行是否超出限制时,以此值为准。对于行尾空格,该值为——空格不参与适配计算。

lineEndPaintAdvance:向调用方报告的可见行宽。对于可折叠空格,该值为零(行尾不可见);对于 pre-wrap 中的保留空格,则是实际空格宽度(保持可见)。

src/layout.ts#L322-L329

const lineEndFitAdvance =
  segKind === 'space' || segKind === 'preserved-space' || segKind === 'zero-width-break'
    ? 0  // Don't count for fitting
    : w
const lineEndPaintAdvance =
  segKind === 'space' || segKind === 'zero-width-break'
    ? 0  // Invisible at line end
    : w  // preserved-space: still visible

这一区分对 full walker 的 pending break 机制至关重要。记录 pending break 时,会同时存储该断点处的 fit 宽度和 paint 宽度:

pendingBreakFitWidth = lineW - segmentWidth + fitAdvance
pendingBreakPaintWidth = lineW - segmentWidth + paintAdvance

fit 宽度与 maxWidth + lineFitEpsilon 比较,决定断点是否有效;paint 宽度则作为输出行的实际宽度上报。这与 CSS 渲染行尾空白的方式完全一致——空白悬挂在容器边界之外,不影响布局。

提示: 如果你使用 layoutWithLines() 实现自定义渲染,line.width绘制宽度,而非容器宽度。以行尾空格结束的行,其绘制宽度可能小于最大宽度,即使该空格在视觉上已经悬挂到了边界之外。

layoutNextLine():迭代器风格的可变宽度布局

layout()layoutWithLines() 对所有行使用固定的 maxWidth,而 layoutNextLine() 则支持逐行迭代,每行可以有不同的宽度。这为文本绕过障碍物流动等可变宽度布局场景提供了支撑:

src/line-break.ts#L651-L662

sequenceDiagram
    participant App as Application
    participant LNL as layoutNextLine()
    participant State as Cursor State

    App->>LNL: start={seg:0, grapheme:0}, width=400
    LNL->>State: Normalize start, walk one line
    LNL-->>App: {text: "Hello world", end: {seg:3, grapheme:0}}

    App->>LNL: start={seg:3, grapheme:0}, width=250
    LNL->>State: Resume from cursor, walk one line
    LNL-->>App: {text: "foo bar", end: {seg:6, grapheme:0}}

    App->>LNL: start={seg:6, grapheme:0}, width=400
    LNL->>State: Resume, walk one line
    LNL-->>App: null (end of text)

其核心设计是游标交接:每次调用返回一个 LayoutLine,其 end 游标作为下一次调用的 start 传入。这使得多栏布局(左栏消费文本,右栏从游标处继续)、障碍物绕排(每行宽度动态变化)以及渐进式渲染成为可能。

内部实现 layoutNextLineRange() 会规范化起始游标(跳过行首空格),定位所在 chunk,然后执行一次单行版本的 full walker——处理完一行后即停止,而非遍历全部文本。

浏览器适配:lineFitEpsilon 与引擎特定行为

换行 walker 在将累计宽度与 maxWidth 比较时,使用了 lineFitEpsilon 容差:

if (newW > maxWidth + lineFitEpsilon) { ... }

这个 epsilon 用于补偿 Pretext 的"片段宽度累加"方式与浏览器原生布局引擎之间的浮点运算差异。不同浏览器的取值各有不同:

浏览器引擎 lineFitEpsilon carryCJKAfterClosingQuote preferPrefixWidthsForBreakableRuns preferEarlySoftHyphenBreak
Chromium 0.005 true false false
Gecko (Firefox) 0.005 false false false
WebKit (Safari) 1/64 (≈0.0156) false true true
Server (no navigator) 0.005 false false false

src/measurement.ts#L65-L101

Safari 使用较大的 epsilon,反映了其内部采用定点算术(1/64 精度)的实现方式。carryCJKAfterClosingQuote 标志触发 Chromium 特有的合并行为:闭合引号后的 CJK 字符留在同一行。preferPrefixWidthsForBreakableRuns 启用 Safari 针对子词断行的替代测量方式,在该场景下,累积前缀宽度比逐字形宽度求和更为精准。preferEarlySoftHyphenBreak 则匹配 Safari 倾向于提前在软连字符处断行而非尽量多放字形的行为。

提示: 如果你的换行计数在边界情况下与浏览器原生布局不一致,lineFitEpsilon 通常是第一个排查点。它是一个调优参数,在"过早断行"(误报)和"应断未断"(漏报)之间寻求平衡。

展望下一篇

至此,我们已经完整走过换行引擎的全部逻辑——从 simple/full 路径的分发,到 pending break 机制与行尾空白处理,再到 fit/paint 宽度的微妙区分和各浏览器的专项调优。这个引擎消费第 2 篇中分析与测量流水线构建的并行数组,以微秒级速度输出行数。

第 4 篇将从换行 walker 中跳出,审视跨浏览器兼容与国际化方面的挑战:引擎配置文件的检测方式、emoji 宽度修正如何弥补 Canvas 与 DOM 之间的偏差、片段度量缓存的结构设计,以及来自 pdf.js 的 bidi 实现如何处理 RTL/LTR 混排文本。