Read OSS

浏览器差异、Emoji 宽度修正与多语言文字支持

高级

前置知识

  • 第 1 篇:架构与两阶段模型
  • 第 2 篇:文本分析流水线
  • 第 3 篇:换行引擎
  • Unicode 基础知识(双向文字、字形簇、emoji 呈现)

浏览器差异、Emoji 宽度修正与多语言文字支持

前三篇文章完整地追踪了从架构设计、文本分析到换行处理的完整流水线,其间将浏览器差异抽象为若干参数(epsilon 值、布尔标志)一笔带过。本篇将正面审视这些差异本身:它们是如何被检测的、为何存在,以及 Pretext 如何在 Chrome、Safari 和 Firefox 之间保持行数的一致性。

我们还将深入探讨国际化方面的挑战——继承自 pdf.js 的双向文字实现、用于补偿 Canvas 与 DOM 宽度差异的 emoji 修正系统,以及 CJK、阿拉伯语、泰语和缅甸语各自在流水线中遵循的文字特定规则。

引擎配置检测

Pretext 通过解析 user agent 字符串来识别浏览器引擎,并设置四个行为标志,这些标志会在整个流水线中传播:

src/measurement.ts#L11-L16

export type EngineProfile = {
  lineFitEpsilon: number
  carryCJKAfterClosingQuote: boolean
  preferPrefixWidthsForBreakableRuns: boolean
  preferEarlySoftHyphenBreak: boolean
}

检测逻辑依赖 navigator.userAgentnavigator.vendor,方式刻意保持简单:

src/measurement.ts#L65-L101

flowchart TD
    A[navigator.userAgent] --> B{vendor = Apple<br/>+ Safari/ present<br/>+ no Chrome/Chromium?}
    B -->|yes| C[Safari/WebKit]
    B -->|no| D{UA contains<br/>Chrome/ or Chromium/<br/>or CriOS/ or Edg/?}
    D -->|yes| E[Chromium]
    D -->|no| F[Gecko/Firefox or fallback]

    C --> G["lineFitEpsilon: 1/64<br/>carryCJK: false<br/>prefixWidths: true<br/>earlySoftHyphen: true"]
    E --> H["lineFitEpsilon: 0.005<br/>carryCJK: true<br/>prefixWidths: false<br/>earlySoftHyphen: false"]
    F --> I["lineFitEpsilon: 0.005<br/>carryCJK: false<br/>prefixWidths: false<br/>earlySoftHyphen: false"]

为什么选择 UA 嗅探而非特性检测?因为这些并不是"特性"——而是各引擎在实现文本排版时存在的细微行为差异。没有可靠的方法能直接检测某个浏览器是否使用 1/64 定点精度进行行适配,或者是否会在右引号后携带 CJK 字符,除非实际执行排版并对比结果。UA 嗅探是这里最务实的选择。

引擎配置以模块级单例的形式缓存,通过 getEngineProfile() 获取。在服务端环境(navigator 未定义时),将使用 Gecko 的默认配置。

Emoji 宽度修正系统

跨浏览器兼容中一个颇为意外的问题是:在 macOS 上的 Chrome 和 Firefox 中,Canvas 的 measureText() 与 DOM 布局对同一 emoji 字符报告的宽度并不一致。在字体大小约 24px 以下时,Canvas 测量的宽度会对每个 emoji 字形簇产生一个固定的虚增偏差。

修正机制分三步执行:

第一步:检测。 对每种字体,分别通过 Canvas 和 DOM 测量 😀 的宽度。如果 Canvas 宽度比 DOM 宽度超出 0.5px 以上,则记录差值:

src/measurement.ts#L123-L151

const canvasW = ctx.measureText('\u{1F600}').width
// ...
const domW = span.getBoundingClientRect().width
if (canvasW - domW > 0.5) {
  correction = canvasW - domW
}

这是整个测量系统中唯一一次 DOM 读取,且按字体缓存——每种字体大小只执行一次,而非每个文本块都执行。

第二步:分段修正。 测量任意分段时,若该字体的 emoji 修正值不为零,修正后的宽度为:

src/measurement.ts#L169-L172

export function getCorrectedSegmentWidth(seg, metrics, emojiCorrection) {
  if (emojiCorrection === 0) return metrics.width
  return metrics.width - getEmojiCount(seg, metrics) * emojiCorrection
}

第三步:统计 emoji 数量。 每个分段中的 emoji 数量通过字形分段延迟计算,并缓存在对应的 SegmentMetrics 对象上:

src/measurement.ts#L152-L167

Safari 无需修正——其 Canvas 与 DOM 的 emoji 宽度保持一致(两者均宽于 fontSize),修正值为 0。

flowchart TD
    A["prepare() called"] --> B{Text may contain emoji?}
    B -->|no| C["emojiCorrection = 0"]
    B -->|yes| D["getEmojiCorrection(font, fontSize)"]
    D --> E{Canvas 😀 width > DOM 😀 width + 0.5?}
    E -->|yes| F["correction = canvasW - domW"]
    E -->|no| G["correction = 0"]
    F --> H["Apply per-segment:<br/>width - emojiCount × correction"]
    G --> I["Use Canvas width directly"]
    C --> I

提示: emoji 检测的快速路径使用 textMayContainEmoji()——一个针对 emoji 相关 Unicode 属性的正则测试。如果整段文本不含任何 emoji 标识符,修正系统将被完全跳过,从而避免 DOM 读取和分段计数的开销。

分段指标缓存与前缀宽度

分段指标缓存是一个两级 Map<font, Map<segment, SegmentMetrics>>,存储在模块作用域中:

src/measurement.ts#L19

const segmentMetricCaches = new Map<string, Map<string, SegmentMetrics>>()

SegmentMetrics 类型承载的信息不只是宽度:

src/measurement.ts#L3-L9

export type SegmentMetrics = {
  width: number
  containsCJK: boolean
  emojiCount?: number                    // Lazily populated
  graphemeWidths?: number[] | null       // Lazily populated
  graphemePrefixWidths?: number[] | null // Lazily populated
}

延迟字段在首次访问时计算,并缓存在同一对象上——这是一种字段级别的记忆化。对于从未需要 overflow-wrap 换行的分段,可以完全避免计算字形宽度。

erDiagram
    FONT_STRING ||--o{ SEGMENT_STRING : "outer Map"
    SEGMENT_STRING ||--|| SEGMENT_METRICS : "inner Map"
    SEGMENT_METRICS {
        number width
        boolean containsCJK
        number emojiCount "optional"
        numberArray graphemeWidths "optional"
        numberArray graphemePrefixWidths "optional"
    }

Safari 的前缀宽度测量(getSegmentGraphemePrefixWidths())值得特别关注。它不是逐个测量每个字形再求和,而是测量累计前缀:"h""he""hel""hell""hello",然后通过相邻前缀宽度的差值得出每个字形的宽度。

src/measurement.ts#L193-L211

为何如此设计?因为文字整形在字符相邻与孤立时可能产生不同的宽度——连字、字距调整和上下文变体都会影响结果。前缀宽度能捕获这些效果,使 Safari 上的词内换行更加精确。Chrome 和 Firefox 的一致性足够好,直接使用单个字形宽度即可。

简化版双向文字算法:来自 pdf.js 的 UAX #9

bidi.ts 中的双向文字实现是 Unicode 双向算法(UAX #9)的简化版本,最初源自 pdf.js,并经由 Sebastian Markbage 的文本排版研究进行了适配。

src/bidi.ts#L1-L6

该实现使用两张查找表对字符进行双向类型分类(L、R、AL、AN、EN 等):一张用于基本拉丁字符范围(0x00–0xFF),另一张用于阿拉伯语(0x0600–0x06FF)。超出这些范围的字符使用简单的区间判断:

src/bidi.ts#L57-L63

function classifyChar(charCode) {
  if (charCode <= 0x00ff) return baseTypes[charCode]
  if (0x0590 <= charCode && charCode <= 0x05f4) return 'R'
  if (0x0600 <= charCode && charCode <= 0x06ff) return arabicTypes[charCode & 0xff]
  if (0x0700 <= charCode && charCode <= 0x08AC) return 'AL'
  return 'L'
}

层级计算实现了 W1–W7(弱类型解析)和 N1–N2(中性类型解析)规则,以及 I1–I2 层级赋值规则。段落方向采用一个简单的启发式策略:只要存在任何双向字符(R、AL、AN),起始层级即设为 1(RTL)。若不存在双向字符,函数会提前返回 null

src/bidi.ts#L65-L162

一个关键的设计决策:双向层级仅作为元数据存在,换行引擎从不读取它们。这些数据仅供需要正确视觉顺序渲染的 prepareWithSegments() 富路径消费者使用。不透明的 prepare() 路径完全跳过双向计算,computeSegmentLevels() 只在富路径中才将字符级层级映射为分段级层级。

src/bidi.ts#L164-L173

多语言文字支持:CJK、阿拉伯语、泰语、缅甸语

每种主要文字系列在流水线中的处理方式各不相同,下表汇总了各文字的特定行为:

文字 分段方式 分析阶段合并 测量方式 换行方式
CJK Intl.Segmenter 按词分组 禁则首尾合并;右引号后携带(Chromium) 拆分为单个字形,并进行禁则重合并 任意两个字形之间均可换行
阿拉伯语 Intl.Segmenter 按词分组 无空格尾部标点聚合;空格+附加符号拆分 作为整体测量(整形敏感) 标准词边界换行
泰语 Intl.Segmenter(基于词典) 无特殊合并 作为分段整体测量 在分段器边界处换行
缅甸语 Intl.Segmenter 按词分组 媒介黏合合并(U+104F) 作为整体测量 在合并后的分段边界换行

isCJK() 函数覆盖了大量 CJK 相关 Unicode 区块——不仅包括 CJK 统一汉字,还涵盖平假名、片假名、谚文、CJK 兼容字符、全角形式,以及直至第 3 平面的多个扩展区块,包括补充表意文字:

src/analysis.ts#L105-L127

禁则字符集精确定义了哪些字符不得出现在行首(右标点,如 )和行尾(左标点,如 ):

src/analysis.ts#L129-L172

阿拉伯语处理中有一个微妙之处需要特别注意:空格后可能紧跟视觉上附着于下一个词的组合附加符号(变音符号)。buildMergedSegmentation() 中的后合并处理会检测阿拉伯文前的 " " + 附加符号 模式,将空格与附加符号拆分——保留空格作为换行机会,同时将附加符号归入下一个词。

src/analysis.ts#L937-L953

提示: 如果你的应用面向特定 locale,请在调用 prepare() 之前使用 setLocale() 重新指定 Intl.Segmenter 的目标语言。这会影响泰语、高棉语、缅甸语等基于词典的分段器所产生的词边界。

展望

至此,我们已经完整地了解了面向浏览器的基础设施:引擎检测、emoji 修正、指标缓存、双向层级,以及各文字脚本的专项处理。结合架构(第 1 篇)、分析流水线(第 2 篇)和行遍历器(第 3 篇),我们已经对文本从原始字符串到行数统计的完整流转过程有了全面的认识。

第 5 篇将把视角从库的内部实现转向消费方:探讨富布局 API 如何驱动真实应用场景——基于二分搜索的聊天气泡自适应收缩、使用 layoutNextLine() 实现的编辑排版障碍绕排,以及通过光栅化 SVG alpha 通道提取多边形轮廓以实现文字环绕的 wrap-geometry 系统。