深入文本分析管道:从原始字符串到可测量的文本段
前置知识
- ›第 1 篇:架构与两阶段模型
- ›基本 Unicode 知识(CJK 范围、组合标记)
- ›Intl.Segmenter API 概念
深入文本分析管道:从原始字符串到可测量的文本段
在第 1 篇中,我们了解到 Pretext 的 prepare() 调用会一次性完成所有耗时工作,从而让 layout() 保持纯算术运算。但 prepare() 内部究竟发生了什么?答案是一条出乎意料的深层管道:空白字符规范化、通过 Intl.Segmenter 进行词语分割、将每个文本段归类为八种断行类型之一、处理日语禁则规则到 URL 检测的多轮合并级联、测量阶段的 CJK 字素拆分,最终组装成并行数组。
本文将从头到尾梳理这条管道,沿着真实的代码路径,从原始字符串一路走到已准备好的句柄。
总调度:analyzeText()
文本分析阶段在内部与测量阶段是分离的。layout.ts 中的 prepareInternal() 函数按顺序调用两个函数:
function prepareInternal(text, font, includeSegments, options) {
const analysis = analyzeText(text, getEngineProfile(), options?.whiteSpace)
return measureAnalysis(analysis, font, includeSegments)
}
analyzeText() 返回一个 TextAnalysis——一个包含规范化字符串、文本段数组、断行类型以及硬断行块的结构体。测量阶段随后遍历这些内容,生成最终的并行数组。我们先来看分析路径。
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 行为一致。
实现上有一道防御性检查——先用一个快速的正则表达式判断是否真的需要规范化,避免对已经整洁的文本进行不必要的字符串分配:
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)。
提示:
pre-wrap模式面向编辑器和输入框等需要保留空格的场景。它并非完整实现 CSS 的pre-wrap——支持普通空格、\t制表符(带浏览器风格的 tab stop)以及\n硬断行。
分割与断行类型分类
规范化之后,文本会被传入以词语粒度配置的 Intl.Segmenter:
分割器输出的每个文本段,再由 splitSegmentByBreakKind() 按断行类型字符进一步拆分。该函数将每个字符归类为八种 SegmentBreakKind 之一:
| 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:
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 行为保持一致。
主合并循环处理来自 Intl.Segmenter 的每个片段,并尝试将其与前一个文本段合并。循环内按优先级依次触发六条合并规则:
-
CJK 右引号跟随(Chromium 特有行为):当前一个 CJK 文本段以右引号结尾,且下一个文本段也是 CJK 时,将两者合并。这与 Chromium 中
」東保持在一起的特定行为一致。 -
禁则处理(行首禁用字符):禁止出现在行首的字符(如
、、。、))会被合并到前一个 CJK 文本段,防止标点孤立于行首。 -
缅甸语中置连接符:当前一个文本段以缅甸语中置字符结尾时,将下一个文本段合并进来。
-
阿拉伯语无空格聚类:当前一个文本段以阿拉伯语尾部标点(
:、.、،、؛)结尾,且下一个词语含有阿拉伯字符时,将两者合并。 -
重复字符连续段:单个非连字符、非破折号字符连续出现时(如
...或===),合并为一个单元。 -
左吸附标点:紧附于前一个词语的标点——
.、,、!、)、"、…、右引号——会与前面的文本合并。这就是为什么better.会被作为一个整体测量。
主循环结束后,两个后处理轮次负责处理向前吸附的行为:
- 转义引号聚类合并:向前扫描,将
\"序列附加到相邻文本段。 - 向前吸附传递:反向扫描,将开括号和开引号(
(、「、")移至下一个文本段的开头。
两个轮次之后都会进行压缩,清除空条目。
合并后的处理:URL、数字与标点链
主合并级联完成后,还有一系列后续处理进一步细化分割结果:
const compacted = mergeGlueConnectedTextRuns(...)
const withMergedUrls = carryTrailingForwardStickyAcrossCJKBoundary(
mergeAsciiPunctuationChains(
splitHyphenatedNumericRuns(
mergeNumericRuns(
mergeUrlQueryRuns(
mergeUrlLikeRuns(compacted))))))
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 的行为一致。
URL 类文本段合并:以 URL 协议头(https:)或 www. 开头的文本段,会吸收其后所有非空白文本段,直到遇到第一个 ? 为止。第二轮(mergeUrlQueryRuns)处理查询字符串部分。这确保了 https://example.com/path?q=1 作为一个可断行的整体,而不会在斜杠处碎裂。
数字段合并:像 7:00 或 3/4 这样由连接字符(:、-、/、×、,、.、+)连接数字的文本段,会合并为一个单元。但对于形如 2024-01-15 的以连字符分隔的数字段,会在连字符处再次拆分,以允许在日期分隔符处断行。
ASCII 标点链:由尾部逗号或冒号连接的词语文本段(如 item1,item2,item3)会合并为一个单元,与浏览器行为保持一致。
最后还有一个阿拉伯语专项处理:拆分行首的 空格+组合标记,让空格保持为断行机会,同时将组合标记附加到后续的阿拉伯语词语上。
测量:Canvas、CJK 拆分与缓存
analyzeText() 返回 TextAnalysis 后,layout.ts 中的 measureAnalysis() 遍历各文本段,构建供行走算法使用的并行数组。
测量循环对不同类型的文本段采用不同的处理方式:
软连字符:正常情况下宽度为零,但会在 fit 和 paint 两个前进量中存储 discretionaryHyphenWidth(即 - 的宽度),以便行走算法在选择此处断行时能计入可见连字符的宽度。
硬断行与制表符:宽度为零——特殊行为由行走算法负责处理。
CJK 文本段:这里发生了最有趣的拆分。当一个文本段包含 CJK 字符时,会用字素粒度的 Intl.Segmenter 将其拆分为单个字素。但拆分过程遵守禁则规则——开括号随下一个字素,闭标点随前一个字素:
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。
提示: 文本段指标缓存以
(font, segment_text)为键,在使用相同字体的所有prepare()调用之间共享。如果你的应用以同一字体渲染 1000 条聊天消息,像the这样的常见词只会通过 Canvas 测量一次。只有在完全切换字体时,才需要调用clearCache()。
从分析结果到已准备好的句柄
最后几步将分析层的块边界映射到准备层的文本段索引(因为 CJK 拆分可能将一个分析层文本段扩展为多个准备层文本段),并可选地计算双向文本(bidi)层级:
块映射由 mapAnalysisChunksToPreparedChunks() 负责,它利用测量循环中构建的查找数组,将硬断行边界从分析层索引转换为准备层索引。
bidi 层级仅在富路径(prepareWithSegments)下计算,且只有在文本确实包含从右到左字符时才会触发——computeBidiLevels() 在未发现 R、AL 或 AN 字符时会提前返回 null,避免对纯从左到右文本做无谓的计算。
展望下一篇
至此,我们已经梳理了从原始字符串到并行数组的完整路径:规范化 → 分割 → 断行类型分类 → 合并级联 → 合并后处理 → Canvas 测量 → CJK 拆分 → 数组组装。最终产出的 PreparedText 句柄包含了断行引擎所需的全部信息。
第 3 篇将深入引擎内部——line-break.ts 中的热路径代码,它以纯算术方式遍历这些数组,在微秒级时间内完成行数计算。我们将看到简单 walker 与完整 walker 分离的意义、行尾空白如何悬挂于行边缘,以及软连字符与 overflow-wrap 断行的交互方式。