Read OSS

深入文本分析管道:从原始字符串到可测量的文本段

高级

前置知识

  • 第 1 篇:架构与两阶段模型
  • 基本 Unicode 知识(CJK 范围、组合标记)
  • Intl.Segmenter API 概念

深入文本分析管道:从原始字符串到可测量的文本段

在第 1 篇中,我们了解到 Pretext 的 prepare() 调用会一次性完成所有耗时工作,从而让 layout() 保持纯算术运算。但 prepare() 内部究竟发生了什么?答案是一条出乎意料的深层管道:空白字符规范化、通过 Intl.Segmenter 进行词语分割、将每个文本段归类为八种断行类型之一、处理日语禁则规则到 URL 检测的多轮合并级联、测量阶段的 CJK 字素拆分,最终组装成并行数组。

本文将从头到尾梳理这条管道,沿着真实的代码路径,从原始字符串一路走到已准备好的句柄。

总调度:analyzeText()

文本分析阶段在内部与测量阶段是分离的。layout.ts 中的 prepareInternal() 函数按顺序调用两个函数:

src/layout.ts#L424-L432

function prepareInternal(text, font, includeSegments, options) {
  const analysis = analyzeText(text, getEngineProfile(), options?.whiteSpace)
  return measureAnalysis(analysis, font, includeSegments)
}

analyzeText() 返回一个 TextAnalysis——一个包含规范化字符串、文本段数组、断行类型以及硬断行块的结构体。测量阶段随后遍历这些内容,生成最终的并行数组。我们先来看分析路径。

src/analysis.ts#L993-L1019

flowchart TD
    A[Raw text string] --> B{WhiteSpace mode?}
    B -->|normal| C[normalizeWhitespaceNormal]
    B -->|pre-wrap| D[normalizeWhitespacePreWrap]
    C --> E[buildMergedSegmentation]
    D --> E
    E --> F[compileAnalysisChunks]
    F --> G[TextAnalysis]

空白字符规范化:normal 与 pre-wrap

在分割之前,必须对空白字符进行规范化,以匹配 CSS 的渲染行为。Pretext 支持两种模式:

normal 模式(默认):将所有连续的空白字符(空格、制表符、换行符、换页符)折叠为单个空格,并去除首尾空格。这与 CSS 的 white-space: normal 行为一致。

src/analysis.ts#L56-L67

实现上有一道防御性检查——先用一个快速的正则表达式判断是否真的需要规范化,避免对已经整洁的文本进行不必要的字符串分配:

export function normalizeWhitespaceNormal(text: string): string {
  if (!needsWhitespaceNormalizationRe.test(text)) return text
  let normalized = text.replace(collapsibleWhitespaceRunRe, ' ')
  // trim leading/trailing space
  ...
}

pre-wrap 模式:保留普通空格、制表符和硬断行,只对行尾符进行规范化(\r\n\n,孤立的 \r\f\n)。

src/analysis.ts#L69-L74

提示: pre-wrap 模式面向编辑器和输入框等需要保留空格的场景。它并非完整实现 CSS 的 pre-wrap——支持普通空格、\t 制表符(带浏览器风格的 tab stop)以及 \n 硬断行。

分割与断行类型分类

规范化之后,文本会被传入以词语粒度配置的 Intl.Segmenter

src/analysis.ts#L79-L84

分割器输出的每个文本段,再由 splitSegmentByBreakKind() 按断行类型字符进一步拆分。该函数将每个字符归类为八种 SegmentBreakKind 之一:

src/analysis.ts#L1-L11

SegmentBreakKind 字符 断行行为
text 常规字符 溢出时可在此前断行
space 可折叠空格 断行后悬挂于行末
preserved-space pre-wrap 模式下的空格 断行后;宽度计入布局
tab pre-wrap 模式下的制表符 断行后;宽度由 tab stop 计算
glue NBSP、NNBSP、WJ、ZWNBSP 不可断行;作为可见内容测量
zero-width-break ZWSP(U+200B) 零宽度的断行机会
soft-hyphen SHY(U+00AD) 断行机会;选择此处断行时显示 -
hard-break pre-wrap 模式下的 \n 强制换行

分类函数 classifySegmentBreakChar() 处理与模式相关的行为——在 normal 模式下空格归为 space,在 pre-wrap 模式下则归为 preserved-space

src/analysis.ts#L321-L334

flowchart TD
    A["Intl.Segmenter output"] --> B[splitSegmentByBreakKind]
    B --> C{Character type?}
    C -->|"U+0020"| D{pre-wrap?}
    D -->|yes| E[preserved-space]
    D -->|no| F[space]
    C -->|"NBSP/NNBSP"| G[glue]
    C -->|"U+200B"| H[zero-width-break]
    C -->|"U+00AD"| I[soft-hyphen]
    C -->|"tab"| J{pre-wrap?}
    J -->|yes| K[tab]
    C -->|other| L[text]

buildMergedSegmentation() 中的合并级联

分析管道的核心是 buildMergedSegmentation()。它接收拆分后的片段,并应用一系列合并规则,使文本段的聚合方式与浏览器的 CSS 行为保持一致。

src/analysis.ts#L795-L956

主合并循环处理来自 Intl.Segmenter 的每个片段,并尝试将其与前一个文本段合并。循环内按优先级依次触发六条合并规则:

  1. CJK 右引号跟随(Chromium 特有行为):当前一个 CJK 文本段以右引号结尾,且下一个文本段也是 CJK 时,将两者合并。这与 Chromium 中 」東 保持在一起的特定行为一致。

  2. 禁则处理(行首禁用字符):禁止出现在行首的字符(如 )会被合并到前一个 CJK 文本段,防止标点孤立于行首。

  3. 缅甸语中置连接符:当前一个文本段以缅甸语中置字符结尾时,将下一个文本段合并进来。

  4. 阿拉伯语无空格聚类:当前一个文本段以阿拉伯语尾部标点(:.،؛)结尾,且下一个词语含有阿拉伯字符时,将两者合并。

  5. 重复字符连续段:单个非连字符、非破折号字符连续出现时(如 ...===),合并为一个单元。

  6. 左吸附标点:紧附于前一个词语的标点——.,!)"、右引号——会与前面的文本合并。这就是为什么 better. 会被作为一个整体测量。

主循环结束后,两个后处理轮次负责处理向前吸附的行为:

  • 转义引号聚类合并:向前扫描,将 \" 序列附加到相邻文本段。
  • 向前吸附传递:反向扫描,将开括号和开引号((")移至下一个文本段的开头。

两个轮次之后都会进行压缩,清除空条目。

合并后的处理:URL、数字与标点链

主合并级联完成后,还有一系列后续处理进一步细化分割结果:

const compacted = mergeGlueConnectedTextRuns(...)
const withMergedUrls = carryTrailingForwardStickyAcrossCJKBoundary(
  mergeAsciiPunctuationChains(
    splitHyphenatedNumericRuns(
      mergeNumericRuns(
        mergeUrlQueryRuns(
          mergeUrlLikeRuns(compacted))))))

src/analysis.ts#L924-L935

flowchart TD
    A[Primary merge output] --> B[mergeGlueConnectedTextRuns]
    B --> C[mergeUrlLikeRuns]
    C --> D[mergeUrlQueryRuns]
    D --> E[mergeNumericRuns]
    E --> F[splitHyphenatedNumericRuns]
    F --> G[mergeAsciiPunctuationChains]
    G --> H[carryTrailingForwardStickyAcrossCJKBoundary]
    H --> I[Arabic space+mark splitting]
    I --> J[Final MergedSegmentation]

Glue 连接的文本段:夹在文本段之间的 NBSP 字符会被吸收,形成一个不可断行的整体。这与 CSS 中 word NBSP word 的行为一致。

src/analysis.ts#L691-L765

URL 类文本段合并:以 URL 协议头(https:)或 www. 开头的文本段,会吸收其后所有非空白文本段,直到遇到第一个 ? 为止。第二轮(mergeUrlQueryRuns)处理查询字符串部分。这确保了 https://example.com/path?q=1 作为一个可断行的整体,而不会在斜杠处碎裂。

src/analysis.ts#L417-L465

数字段合并:像 7:003/4 这样由连接字符(:-/×,.+)连接数字的文本段,会合并为一个单元。但对于形如 2024-01-15 的以连字符分隔的数字段,会在连字符处再次拆分,以允许在日期分隔符处断行。

src/analysis.ts#L541-L689

ASCII 标点链:由尾部逗号或冒号连接的词语文本段(如 item1,item2,item3)会合并为一个单元,与浏览器行为保持一致。

最后还有一个阿拉伯语专项处理:拆分行首的 空格+组合标记,让空格保持为断行机会,同时将组合标记附加到后续的阿拉伯语词语上。

测量:Canvas、CJK 拆分与缓存

analyzeText() 返回 TextAnalysis 后,layout.ts 中的 measureAnalysis() 遍历各文本段,构建供行走算法使用的并行数组。

src/layout.ts#L191-L392

测量循环对不同类型的文本段采用不同的处理方式:

软连字符:正常情况下宽度为零,但会在 fit 和 paint 两个前进量中存储 discretionaryHyphenWidth(即 - 的宽度),以便行走算法在选择此处断行时能计入可见连字符的宽度。

硬断行与制表符:宽度为零——特殊行为由行走算法负责处理。

CJK 文本段:这里发生了最有趣的拆分。当一个文本段包含 CJK 字符时,会用字素粒度的 Intl.Segmenter 将其拆分为单个字素。但拆分过程遵守禁则规则——开括号随下一个字素,闭标点随前一个字素:

src/layout.ts#L279-L319

if (
  kinsokuEnd.has(unitText) ||         // Opening bracket stays with next
  kinsokuStart.has(grapheme) ||       // Closing punct stays with prev
  leftStickyPunctuation.has(grapheme) // Period/comma stays with prev
) {
  unitText += grapheme
  continue
}

普通文本:通过 getSegmentMetrics() 测量,该函数使用共享的 Canvas 上下文。对于含多个字素的词语类文本段,会预先计算每个字素的宽度,以支持 overflow-wrap: break-word。在 Safari 上,还会额外计算累积前缀宽度,因为 Safari 的文本排版结果与逐个字素宽度之和存在差异。

lineEndFitAdvance 与 lineEndPaintAdvance:对于空格和零宽度断行,fit 前进量为零(行尾空白不参与行适配判断),但 paint 前进量各有不同(可折叠空格不可见,保留空格可见)。我们将在第 3 篇中深入探讨这一关键区别。

flowchart TD
    A[TextAnalysis segment] --> B{Segment kind?}
    B -->|soft-hyphen| C["width=0, fitAdvance=hyphenWidth"]
    B -->|hard-break/tab| D["width=0, advance=0"]
    B -->|"text (CJK)"| E[Split into graphemes]
    E --> F[Apply kinsoku merging]
    F --> G[Measure each unit]
    B -->|"text (other)"| H[Measure whole segment]
    H --> I{word-like + multi-grapheme?}
    I -->|yes| J[Pre-compute grapheme widths]
    I -->|no| K[Store null for breakableWidths]
    G --> L[Push to parallel arrays]
    J --> L
    K --> L
    C --> L
    D --> L

每个测量完成的文本段通过 pushMeasuredSegment() 写入,该函数同时向所有并行数组追加数据,并在遇到非简单文本段类型时将 simpleLineWalkFastPath 标志置为 false

src/layout.ts#L220-L241

提示: 文本段指标缓存以 (font, segment_text) 为键,在使用相同字体的所有 prepare() 调用之间共享。如果你的应用以同一字体渲染 1000 条聊天消息,像 the 这样的常见词只会通过 Canvas 测量一次。只有在完全切换字体时,才需要调用 clearCache()

从分析结果到已准备好的句柄

最后几步将分析层的块边界映射到准备层的文本段索引(因为 CJK 拆分可能将一个分析层文本段扩展为多个准备层文本段),并可选地计算双向文本(bidi)层级:

src/layout.ts#L361-L392

块映射由 mapAnalysisChunksToPreparedChunks() 负责,它利用测量循环中构建的查找数组,将硬断行边界从分析层索引转换为准备层索引。

bidi 层级仅在富路径(prepareWithSegments)下计算,且只有在文本确实包含从右到左字符时才会触发——computeBidiLevels() 在未发现 R、AL 或 AN 字符时会提前返回 null,避免对纯从左到右文本做无谓的计算。

展望下一篇

至此,我们已经梳理了从原始字符串到并行数组的完整路径:规范化 → 分割 → 断行类型分类 → 合并级联 → 合并后处理 → Canvas 测量 → CJK 拆分 → 数组组装。最终产出的 PreparedText 句柄包含了断行引擎所需的全部信息。

第 3 篇将深入引擎内部——line-break.ts 中的热路径代码,它以纯算术方式遍历这些数组,在微秒级时间内完成行数计算。我们将看到简单 walker 与完整 walker 分离的意义、行尾空白如何悬挂于行边缘,以及软连字符与 overflow-wrap 断行的交互方式。