Pretext 开源项目解读
项目信息
- 项目名称:Pretext
- 项目描述: Pretext 是一个纯 JS/TS 文本布局与测量库,通过 Canvas 测量 + 纯算术断行,在不触发 DOM 回流的情况下精确计算多语言文本的行数和高度。
- 项目地址:https://github.com/chenglou/pretex
- 应用 Demos:https://chenglou.me/pretext/
解决的核心痛点:
DOM 回流的性能诅咒 — 传统的文本测量(
getBoundingClientRect、offsetHeight)触发同步布局回流。500 个文本块可导致每帧 30ms+ 的卡顿。Pretext 将昂贵工作集中在一次性prepare()中,layout()变成纯算术操作(~0.0002ms/文本)。多语言文本布局的复杂性 — CSS 文本布局涉及 CJK 逐字断行、阿拉伯语双向文本、泰语/高棉语字典分词、Emoji 宽度修正等大量细节。Pretext 用浏览器的 Canvas 字体引擎作为 ground truth,支持所有 Unicode 语言。
Web 平台上缺失的多行 shrinkwrap — CSS 没有原生的多行文本宽度收缩适配 API。Pretext 提供了
walkLineRanges()和measureNaturalWidth()来精确计算。
目标用户:前端应用开发者 — 文本虚拟化、自定义布局引擎(masonry、JS 驱动的 flexbox)、聊天 UI、富文本编辑器、Canvas/SVG 渲染、开发时文本溢出验证。
1. 项目概览
1.1 项目定位与核心价值
Pretext 是一个纯 JavaScript/TypeScript 文本布局与测量库,通过跳过 DOM 测量来实现高性能多行文本排版,同时保持与浏览器行为的高度一致。
一句话定位:面向前端的、与浏览器行为高度一致的高性能文本测量与布局引擎库。
解决的核心痛点:
DOM 回流的性能诅咒 — 传统的文本测量(
getBoundingClientRect、offsetHeight)触发同步布局回流。500 个文本块可导致每帧 30ms+ 的卡顿。Pretext 将昂贵工作集中在一次性prepare()中,layout()变成纯算术操作(~0.0002ms/文本)。多语言文本布局的复杂性 — CSS 文本布局涉及 CJK 逐字断行、阿拉伯语双向文本、泰语/高棉语字典分词、Emoji 宽度修正等大量细节。Pretext 用浏览器的 Canvas 字体引擎作为 ground truth,支持所有 Unicode 语言。
Web 平台上缺失的多行 shrinkwrap — CSS 没有原生的多行文本宽度收缩适配 API。Pretext 提供了
walkLineRanges()和measureNaturalWidth()来精确计算。
目标用户:前端应用开发者 — 文本虚拟化、自定义布局引擎(masonry、JS 驱动的 flexbox)、聊天 UI、富文本编辑器、Canvas/SVG 渲染、开发时文本溢出验证。
1.2 技术栈与选型对比
| 技术选择 | 方案 | 替代方案及放弃原因 |
|---|---|---|
| 文本测量 | Canvas measureText() | DOM 测量 → 触发同步回流 |
| 文本分词 | Intl.Segmenter(浏览器原生) | 自建分词器 → 维护成本高,覆盖面窄 |
| 运行时 | ESM only + Bun 工具链 | CJS → 不支持 tree-shaking |
| Bidi 处理 | 简化 Unicode Bidi(来自 pdf.js) | 完整 UBA → 属于渲染层,不在布局层范围 |
| 架构模式 | 两阶段 Prepare-Layout 分离 | 单次测量 → 无法在 resize 时复用预计算数据 |
| 字体 | 指定字体名 | system-ui → Canvas/DOM 分辨率不一致 |
1.3 核心技术亮点
- 两阶段分离:
prepare()一次性分析+测量,layout()纯算术(零 DOM/Canvas/字符串调用) - 8 种段类型:完整模拟 CSS 空白处理、不间断胶合、软连字符、零宽断行机会等
- Emoji 自动修正:通过 Canvas vs DOM 对比自动检测并修正 Chrome/Firefox 的 Emoji 宽度差异
- 浏览器自适应:自动检测引擎(Chrome/Safari/Firefox)并适配不同的行适配容差和行为策略
- 支持多语言:20+ 语言的长文本语料测试,包括阿拉伯语、希伯来语、印地语、中文、日语、韩语、泰语、高棉语、缅甸语、乌尔都语
- 渐进式 API 层次:快速路径(仅高度)、中等路径(含行文本)、低级路径(迭代器+无字符串分配)
2. 整体架构设计
2.1 架构概述
Pretext 采用两阶段分离 + 分层模块化架构。核心设计将一个文本布局问题拆解为三个阶段:
- 准备阶段 (Prepare):一次性昂贵的文本分析 + Canvas 测量,缓存所有中间结果
- 布局阶段 (Layout):纯算术的行行走,基于缓存的段宽度计算行数/高度/行文本
- 富文本辅助层 (Rich Inline):在核心引擎之上提供内联富文本的边界空白折叠和原子项处理
模块之间的依赖是单向的、严格分层的:
analysis.ts(文本分析)是底层,被layout.ts和bidi.ts依赖measurement.ts(Canvas 测量)是底层,被layout.ts依赖line-break.ts(行行走引擎)是中间层,被layout.ts依赖line-text.ts(行文本构造)是辅助层,被layout.ts和rich-inline.ts依赖bidi.ts(双向文本)是可选辅助层,仅被layout.ts在 rich 路径中依赖layout.ts(核心库 API)是顶层,聚合所有模块并暴露公共 APIrich-inline.ts(富文本内联)是独立的辅助模块,依赖layout.ts和line-text.ts
2.2 整体架构图
+====================================================================================+
| Pretext 公共 API 层 |
| |
| prepare() prepareWithSegments() layout() layoutWithLines() setLocale() |
| walkLineRanges() measureLineStats() measureNaturalWidth() |
| layoutNextLine() layoutNextLineRange() materializeLineRange() |
| clearCache() |
+===================================+===============================================+
|
+-----------------v------------------+
| layout.ts |
| (核心聚合 + API 暴露) |
| 两阶段协调、类型定义、 |
| locale 管理、缓存清除 |
+--+--------+--------+--------+------+
| | | |
+-------------v-+ +---v------+ +v-------+ +v-------------+
| analysis.ts | | bidi.ts | | line- | | line-text.ts |
| (文本分析) | | (双向) | | break | | (行文本构造) |
| | | | | .ts | | |
| 空白规范化 | | Unicode | | (行行走| | 字形分割缓存 |
| Intl.Segmenter| | Bidi | | 引擎) | | 段范围→字符串 |
| 胶合规则 | | 分类+ | | | | 软连字符处理 |
| CJK/禁则处理 | | 层级计算 | | 段宽度 | | WeakMap 缓存 |
| keep-all 策略 | | | | 累加 | | |
+------+--------+ +---------+ | 断行判定| +--------------+
| | 行范围 |
| | 输出 |
| +----------+
|
+---------v----------+
| measurement.ts |
| (Canvas 测量引擎) |
| |
| Canvas 上下文管理 |
| 段宽度缓存 |
| Emoji 宽度修正 |
| 引擎 Profile 检测 |
| 字形级宽度预计算 |
| 字体尺寸解析 |
+--------------------+分层职责说明:
| 层 | 模块 | 职责 | 依赖 |
|---|---|---|---|
| API 层 | layout.ts | 公共 API 暴露、两阶段协调、类型定义 | 所有下层模块 |
| 分析层 | analysis.ts | 文本规范化、Intl.Segmenter 分词、胶合规则、CJK/keep-all 预处理 | 无 |
| 测量层 | measurement.ts | Canvas 测量、段宽度缓存、Emoji 修正、引擎 Profile 检测 | analysis.ts(isCJK) |
| 行走层 | line-break.ts | 段宽度累加、断行判定、行范围输出(4 种模式) | analysis.ts(类型), measurement.ts(Profile) |
| 文本层 | line-text.ts | 字形缓存、段范围→字符串拼接、软连字符可视化 | analysis.ts(类型), layout.ts(类型) |
| Bidi 层 | bidi.ts | Unicode Bidi 分类、嵌入层级计算 | 无(纯数据驱动) |
| 富文本层 | rich-inline.ts | 跨项空白折叠、原子项处理、行游标状态机 | layout.ts, line-text.ts |
2.3 目录结构图
pretext/
├── src/ # 【核心基建】核心库源码 — 发布为 @chenglou/pretext
│ ├── layout.ts # 【核心基建】公共 API 聚合层 — prepare/layout 系列 20+ 导出函数
│ ├── analysis.ts # 【核心基建】文本分析引擎 — 空白规范化/Intl.Segmenter 分词/胶合规则/禁则/keep-all
│ ├── measurement.ts # 【核心基建】Canvas 测量引擎 — 段宽度缓存/Emoji 修正/引擎 Profile 检测/字形级宽度
│ ├── line-break.ts # 【核心基建】行行走引擎 — 段宽度累加/断行判定/行范围输出,四种输出模式
│ ├── line-text.ts # 【核心基建】行文本构造器 — 字形级段拆分缓存/段范围→字符串拼接/软连字符可视化
│ ├── bidi.ts # 【核心基建】双向文本元数据 — Unicode Bidi 分类/嵌入层级计算,仅 rich 路径使用
│ ├── rich-inline.ts # 【核心基建】富文本内联布局 — 跨项空白折叠/原子 chip/extraWidth,独立 export 路径
│ ├── test-data.ts # 【工具集】测试数据共享 — 精度检查和基准面复用同一语料
│ ├── layout.test.ts # 【质量保证】小型持久不变测试 — Bun Test 套件
│ ├── text-modules.d.ts # 【配置】文本模块类型声明 — .txt/.html 导入类型
│ └── generated/ # 【工具集】自动生成数据
│ └── bidi-data.ts # 【工具集】Unicode 17.0 Bidi 分类范围 — 由 generate:bidi-data 生成
├── pages/ # 【UI 视图】浏览器端页面 — 开发服务器 + 精度验证 + Demo
│ ├── accuracy.html # 【UI 视图】精度检查页 — 多浏览器多宽度行级对比
│ ├── accuracy.ts # 【质量保证】精度检查逻辑 — 浏览器 sweep + 逐行诊断
│ ├── benchmark.html # 【UI 视图】基准面页 — 性能吞吐量对比
│ ├── benchmark.ts # 【质量保证】基准面逻辑 — Chrome/Safari 性能快照
│ ├── corpus.html # 【UI 视图】语料诊断页 — 长文本断行对比
│ ├── corpus.ts # 【质量保证】语料诊断逻辑 — 段级偏移计算 + 行级对比
│ ├── probe.html # 【UI 视图】探针诊断页 — 单宽度精确断行
│ ├── probe.ts # 【质量保证】探针诊断逻辑 — 细粒度断行追踪
│ ├── diagnostic-utils.ts # 【工具集】字形安全诊断工具 — Range/span 提取器
│ ├── report-utils.ts # 【工具集】报告传输工具 — hash 通道 + POST 侧通道
│ ├── emoji-test.html # 【UI 视图】Emoji 测试页
│ ├── justification-comparison.html # 【UI 视图】对齐比较页
│ ├── assets/ # 【UI 视图】静态资源
│ │ ├── claude-symbol.svg # 【UI 视图】Claude 符号
│ │ └── openai-symbol.svg # 【UI 视图】OpenAI 符号
│ └── demos/ # 【UI 视图】功能 Demo 合集 — GitHub Pages 站点
│ ├── index.html # 【UI 视图】Demo 索引页 — 站点根页面
│ ├── bubbles.html # 【UI 视图】气泡排版 Demo — shrinkwrap 动态布局
│ ├── bubbles.ts # 【UI 视图】气泡排版逻辑 — walkLineRanges 狗粮
│ ├── bubbles-shared.ts # 【工具集】气泡共享逻辑 — cell/glyph 类型
│ ├── dynamic-layout.html # 【UI 视图】动态布局 Demo — 双栏浮动绕排
│ ├── dynamic-layout.ts # 【UI 视图】动态布局逻辑 — layoutNextLineRange 狗粮
│ ├── dynamic-layout-text.ts # 【工具集】动态布局文本数据
│ ├── editorial-engine.html # 【UI 视图】编辑引擎 Demo — 行级渲染引擎
│ ├── editorial-engine.ts # 【UI 视图】编辑引擎逻辑 — 光学对齐/断行优化
│ ├── markdown-chat.html # 【UI 视图】Markdown 聊天 Demo — 虚拟化聊天
│ ├── markdown-chat.ts # 【UI 视图】Markdown 聊天逻辑 — rich-inline + pre-wrap 狗粮
│ ├── markdown-chat.model.ts # 【UI 视图】聊天状态模型 — 消息/渲染状态机
│ ├── markdown-chat.data.ts # 【工具集】聊天测试数据
│ ├── rich-note.html # 【UI 视图】富文本笔记 Demo — rich-inline 狗粮
│ ├── rich-note.ts # 【UI 视图】富文本笔记逻辑 — 内联富文本渲染
│ ├── rich-note.model.ts # 【UI 视图】笔记状态模型
│ ├── accordion.html # 【UI 视图】手风琴 Demo
│ ├── accordion.ts # 【UI 视图】手风琴逻辑
│ ├── justification-comparison.html # 【UI 视图】对齐比较 Demo
│ ├── justification-comparison.ts # 【UI 视图】对齐比较逻辑
│ ├── justification-comparison.model.ts # 【UI 视图】对齐比较模型
│ ├── justification-comparison.data.ts # 【工具集】对齐比较数据
│ ├── justification-comparison.ui.ts # 【UI 视图】对齐比较 UI
│ ├── variable-typographic-ascii.html # 【UI 视图】可变排版 ASCII Demo
│ ├── variable-typographic-ascii.ts # 【UI 视图】可变排版 ASCII 逻辑
│ ├── wrap-geometry.ts # 【工具集】换行几何计算
│ ├── svg.d.ts # 【配置】SVG 类型声明
│ └── masonry/ # 【UI 视图】瀑布流 Demo
│ ├── index.html # 【UI 视图】瀑布流 HTML
│ ├── index.ts # 【UI 视图】瀑布流逻辑 — 动态 masonry
│ └── shower-thoughts.json # 【工具集】瀑布流数据
├── scripts/ # 【工具集】自动化脚本 — 构建/检查/基准面/语料
│ ├── accuracy-check.ts # 【质量保证】精度检查 — Chrome 浏览器 sweep
│ ├── benchmark-check.ts # 【质量保证】基准面检查 — Chrome 性能快照
│ ├── browser-automation.ts # 【核心基建】浏览器自动化 — CDP 连接/页面管理/报告通道
│ ├── report-server.ts # 【工具集】报告 HTTP 端点 — POST 侧通道接收
│ ├── corpus-check.ts # 【质量保证】语料诊断 — 单语料单宽度
│ ├── corpus-sweep.ts # 【质量保证】语料扫频 — step=10 宽度遍历
│ ├── corpus-status.ts # 【工具集】语料状态 — 生成 corpora/dashboard.json
│ ├── corpus-font-matrix.ts # 【工具集】字体矩阵 — 多字体语料对比
│ ├── corpus-taxonomy.ts # 【工具集】差异分类 — mismatch 语义桶
│ ├── pre-wrap-check.ts # 【质量保证】pre-wrap 模式检查
│ ├── keep-all-check.ts # 【质量保证】keep-all 模式检查
│ ├── symbol-check.ts # 【质量保证】符号断行检查
│ ├── letter-spacing-check.ts # 【质量保证】letter-spacing 检查
│ ├── probe-check.ts # 【质量保证】探针诊断 — 断行追踪
│ ├── build-demo-site.ts # 【工具集】构建静态 Demo 站点 → site/
│ ├── package-smoke-test.ts # 【质量保证】包冒烟测试 — tarball JS/TS 消费端验证
│ ├── generate-bidi-data.ts # 【工具集】生成 Bidi 数据 — Unicode 17.0
│ ├── status-dashboard.ts # 【工具集】状态面板生成 → status/dashboard.json
│ └── unicode/ # 【工具集】Unicode 元数据
│ └── DerivedBidiClass-17.0.0.txt # 【工具集】Unicode Bidi 分类原始数据
├── corpora/ # 【工具集】多语言长文本语料 — 18 种语言 20+ 语料
│ ├── ar-al-bukhala.txt # 【质量保证】阿拉伯语 — 古典文学
│ ├── ar-risalat-al-ghufran-part-1.txt # 【质量保证】阿拉伯语 — 古典文学
│ ├── en-gatsby-opening.txt # 【质量保证】英语 — 现代文学
│ ├── he-masaot-binyamin-metudela.txt # 【质量保证】希伯来语 — 历史文本
│ ├── hi-eidgah.txt # 【质量保证】印地语 — 现代文学
│ ├── ja-kumo-no-ito.txt # 【质量保证】日语 — 芥川龙之介《蜘蛛の糸》
│ ├── ja-rashomon.txt # 【质量保证】日语 — 芥川龙之介《羅生門》
│ ├── km-prachum-reuang-preng-khmer-volume-7-stories-1-10.txt # 【质量保证】高棉语
│ ├── ko-sonagi.txt # 【质量保证】韩语 — 现代文学
│ ├── ko-unsu-joh-eun-nal.txt # 【质量保证】韩语 — 现代文学
│ ├── my-bad-deeds-return-to-you-teacher.txt # 【质量保证】缅甸语
│ ├── my-cunning-heron-teacher.txt # 【质量保证】缅甸语
│ ├── th-nithan-vetal-story-1.txt # 【质量保证】泰语
│ ├── th-nithan-vetal-story-7.txt # 【质量保证】泰语
│ ├── ur-chughd.txt # 【质量保证】乌尔都语 — Nastaliq/Naskh
│ ├── zh-guxiang.txt # 【质量保证】中文 — 鲁迅《故鄉》
│ ├── zh-zhufu.txt # 【质量保证】中文 — 鲁迅《祝福》
│ ├── mixed-app-text.txt # 【质量保证】混合应用文本 — URL/emoji/符号/软连字符
│ ├── chrome-step10.json # 【工具集】Chrome step=10 语料扫频快照
│ ├── safari-step10.json # 【工具集】Safari step=10 语料扫频快照
│ ├── dashboard.json # 【工具集】语料面板 — 机读状态
│ ├── sources.json # 【配置】语料来源元数据
│ ├── README.md # 【配置】语料说明
│ ├── STATUS.md # 【配置】语料状态指针
│ └── TAXONOMY.md # 【配置】差异分类词汇 — mismatch 语义桶定义
├── accuracy/ # 【工具集】浏览器精度快照 — 三浏览器数据
│ ├── chrome.json # 【工具集】Chrome 精度数据
│ ├── firefox.json # 【工具集】Firefox 精度数据
│ ├── safari.json # 【工具集】Safari 精度数据
│ └── letter-spacing.json # 【工具集】letter-spacing 精度数据
├── benchmarks/ # 【工具集】性能基准面快照
│ ├── chrome.json # 【工具集】Chrome 基准面
│ └── safari.json # 【工具集】Safari 基准面
├── status/ # 【工具集】主状态面板
│ └── dashboard.json # 【工具集】聚合精度 + 基准面状态
├── shared/ # 【工具集】全局共享
│ └── navigation-state.ts # 【工具集】页面导航状态管理
├── research-data/ # 【工具集】研究数据
│ └── system-ui-size-scan.json # 【工具集】system-ui 尺寸扫描 — font resolution mismatch 证据
├── .github/ # 【配置】CI/CD
│ └── workflows/
│ └── pages.yml # 【配置】GitHub Pages 自动部署
├── .cursor/ # 【配置】编辑器
│ └── rules/
│ └── use-bun-instead-of-node-vite-npm-pnpm.mdc # 【配置】Cursor 规则 — 强制使用 Bun
├── package.json # 【配置】NPM 包元数据 — @chenglou/pretext v0.0.7
├── tsconfig.json # 【配置】TS 项目配置 — 开发模式 (noEmit)
├── tsconfig.build.json # 【配置】TS 构建配置 — 发布模式 (emit dist/)
├── oxlintrc.json # 【配置】Oxlint 规则
├── knip.config.ts # 【配置】Knip 死代码扫描
├── bun.lock # 【配置】Bun 依赖锁
├── README.md # 【配置】项目说明 — 公共 API 参考
├── DEVELOPMENT.md # 【配置】开发指南 — 命令面/快照/分析流程
├── TODO.md # 【配置】优先级 — 当前和下一步
├── CHANGELOG.md # 【配置】变更日志 — 版本历史
├── STATUS.md # 【配置】状态面板指针
├── RESEARCH.md # 【配置】研究日志 — 所有实验/发现/结论
├── SECURITY.md # 【配置】安全策略
├── AGENTS.md # 【配置】Claude Code 项目指令
├── thoughts.md # 【配置】设计思维记录
├── LICENSE # 【配置】MIT 许可证
└── .gitignore # 【配置】Git 忽略规则3. 模块依赖与调用关系
3.1 全局入口与核心路由
逻辑说明:用户通过 prepare() 或 prepareWithSegments() 进入系统。这两个函数内部协调 analysis.ts(文本分析)、measurement.ts(Canvas 测量)、和可选的 bidi.ts(Bidi 元数据)。返回的不透明句柄(PreparedText 或 PreparedTextWithSegments)包含所有 layout 热路径需要的预计算数据。后续的 layout()、layoutWithLines()、walkLineRanges() 等 API 接收准备好的句柄,委托给 line-break.ts 做纯算术的行行走,需要时调用 line-text.ts 构造文本字符串。
- 调用拓扑 (plainText):
用户代码
|
v
prepare(text, font, options?)
|
+---> analyzeText(text, options) [analysis.ts]
| +---> Intl.Segmenter (word) 词级分段
| +---> Intl.Segmenter (grapheme) 字形级分段
| +---> normalizeWhitespaceNormal() 空白规范化
| +---> CJK/禁则/keep-all 预处理
| +---> 返回 TextAnalysis
|
+---> getSegmentMetrics() × N [measurement.ts]
| +---> canvas.measureText(segment) 各段宽度
| +---> Emoji 修正 (如需要)
| +---> 缓存到 Map<font, Map<segment, metrics>>
|
+---> computeSegmentLevels() [bidi.ts] (仅 rich 路径)
| +---> Unicode Bidi 分类
| +---> 嵌入层级计算
| +---> 映射到段
|
+---> 返回 PreparedText / PreparedTextWithSegments
layout(prepared, maxWidth, lineHeight)
|
+---> countPreparedLines() [line-break.ts]
| +---> 宽度累加
| +---> 断行判定 (epsilon 容差)
| +---> 返回 { lineCount, height }
|
+---> 返回 LayoutResult
layoutWithLines(prepared, maxWidth, lineHeight)
|
+---> walkPreparedLinesRaw() [line-break.ts]
| +---> 逐行宽度累加
| +---> 每行触发回调: (start, end, width)
|
+---> buildLineTextFromRange() × N [line-text.ts]
| +---> Intl.Segmenter (grapheme) 按需字形拆分
| +---> 段文本拼接
| +---> 软连字符可见化
|
+---> 返回 LayoutLinesResult { lines: LayoutLine[] }2.2 核心业务实体与关联
实体定义:
- PreparedText:不透明句柄,包含所有布局热路径需要的预计算数据(段宽度、段类型、字形宽度数组等)。快速路径使用,不暴露内部结构。
- PreparedTextWithSegments:富文本句柄,在 PreparedText 基础上额外暴露段文本和 Bidi 元数据,用于手动行布局。
- LayoutLine / LayoutLineRange:一行文本的表示。LayoutLine 包含实际文本字符串,LayoutLineRange 仅包含游标范围(更轻量)。
- LayoutCursor:段/字形双级游标,用于在准备好的文本中精确定位。
- SegmentMetrics:单个段的 Canvas 测量结果,包含宽度、是否含 CJK、Emoji 计数、字形级宽度数组。
- TextAnalysis:文本分析产物,包含规范化文本、段数组、段类型数组、以及预计算的分块信息。
实体引用拓扑 (plainText):
[PrepareOptions]
|
v
[TextAnalysis] 1 ---> 1 [normalized: string]
| [segments: string[]]
| [kinds: SegmentBreakKind[]]
| [chunks: AnalysisChunk[]]
|
v
[PreparedText] 1 ---> 1 [PreparedCore]
| [widths: number[]]
| [kinds: SegmentBreakKind[]]
| [breakableFitAdvances: number[][]]
| [letterSpacing: number]
| [segLevels: Int8Array] (仅 rich)
|
+--- PreparedTextWithSegments (扩展)
| [segments: string[]]
| [starts: number[]]
|
v
[layout()] ---> [LayoutResult { height, lineCount }]
[layoutWithLines()] ---> [LayoutLinesResult { height, lineCount, lines: LayoutLine[] }]
[walkLineRanges()] ---> N × [LayoutLineRange { width, start, end }]
|
v
[layoutNextLineRange()] + [materializeLineRange()]
| |
v v
[LayoutLineRange] -------> [LayoutLine { text, width, start, end }]
{cursor: LayoutCursor} {cursor: LayoutCursor}4. 核心模块详解
模块一:文本分析引擎 (analysis.ts)
设计说明:文本分析是
prepare()的第一步,负责将原始文本转化为结构化的段数组。核心流程是:- 空白规范化(
normalizeWhitespaceNormal):white-space: normal下将连续的空白折叠为单个空格,pre-wrap下保留空格和硬换行 - 词级分段(
Intl.Segmenter,granularity: 'word'):按语言规则将文本切分为"词"和"非词"段 - 标点胶合:将跟随的标点合并到前一个词段(如 "hello." 合并为一个段)
- CJK 字形级再分段:将 CJK 词段拆分为单字,然后在禁则规则下重新合并(行首/行尾禁则字符)
- keep-all 策略:CJK/Hangul 文本中禁止词内断行
- 特殊段处理:软连字符(
)、零宽空格()、不间断空格()等 - 输出结构化的
TextAnalysis,以并行数组方式存储段文本、段类型、起始位置
- 空白规范化(
内部结构图 (plainText):
+---------------------------+ +---------------------------+
| normalizeWhitespaceNormal | | getWhiteSpaceProfile |
| (空白字符规范化) | | (模式配置解析) |
+-----------+---------------+ +---------------------------+
|
v
+---------------------------+
| Intl.Segmenter (word) |
| (浏览器原生的词级分词) |
+-----------+---------------+
|
v
+-----------+---------------+
| mergePunctuationIntoWords |
| (标点→前词胶合) |
+-----------+---------------+
|
v
+-----------+---------------+
| splitCJKGraphemes + |
| applyKinsoku | <--- kinsokuStart / kinsokuEnd
| (CJK 字形拆分 + 禁则合并) |
+-----------+---------------+
|
v
+-----------+---------------+
| applyKeepAllPreprocessing | <--- canContinueKeepAllTextRun
| (keep-all 断行策略) |
+-----------+---------------+
|
v
+-----------+---------------+
| classifySegmentKinds |
| (段类型分类: text/space/ |
| glue/soft-hyphen 等) |
+-----------+---------------+
|
v
+---------------------------+
| TextAnalysis |
| { normalized, segments[], |
| kinds[], starts[], |
| chunks[] } |
+---------------------------+模块二:Canvas 测量引擎 (measurement.ts)
设计说明:Canvas 测量是
prepare()的第二步,负责测量每个段的精确宽度。核心设计要点:- 延迟初始化:Canvas 上下文在首次测量时才创建(
OffscreenCanvas优先,回退到 DOM Canvas) - 两级缓存:
Map<font, Map<segment, SegmentMetrics>>,跨文本共享,可通过clearCache()重置 - Emoji 修正:Chrome/Firefox 在字号 < 24px 时 Canvas 测量的 Emoji 宽度比 DOM 宽。修正量通过一次 DOM 比较读取自动检测。Safari 的 Canvas 和 DOM 一致,修正量为 0
- 字形级宽度预计算:对于可断行的长段(如 URL),预先测量每个字形的累积宽度(
breakableFitAdvances),供行行走引擎使用 - 引擎 Profile 检测:自动检测当前浏览器的断行特性(行适配容差、CJK 引号后处理等),使算法适配 Chrome/Safari/Firefox 的差异
- 延迟初始化:Canvas 上下文在首次测量时才创建(
内部结构图 (plainText):
+---------------------------+
| getMeasureContext() |
| (Canvas 2D 上下文单例) |
+-----------+---------------+
|
v
+---------------------------+ +---------------------------+
| getSegmentMetrics() | --> | segmentMetricCaches |
| (段宽度 + 字形宽度测量) | | Map<font, Map<seg, metrics>|
+-----------+---------------+ +---------------------------+
|
+---> canvas.measureText(seg).width
+---> 字形级 measureText(prefix) 累加 (大段用 pair-context)
+---> Emoji 修正 (如需要)
|
v
+-----------+---------------+
| getEngineProfile() | 缓存到 cachedEngineProfile
| (浏览器引擎特征检测) |
| - lineFitEpsilon | 检测 Chrome(0.005) vs Safari(1/64)
| - preferPrefixWidths | Safari 前缀宽度策略
| - preferEarlySoftHyphen | Chrome 软连字符策略
| - carryCJKAfterClosingQuote|
+-----------+---------------+
|
v
+---------------------------+
| getCorrectedSegmentWidth |
| (应用 Emoji 修正的段宽度) |
+---------------------------+模块三:行行走引擎 (line-break.ts)
设计说明:行行走是布局的热路径核心。它接收预计算的段数据(宽度、类型、字形级宽度数组),通过纯算术方式模拟浏览器的断行行为。核心算法:
- 快速路径:对于纯普通文本(无特殊段类型、无 letter-spacing),使用简化的老式行走器
- 通用路径:逐段累加宽度,在宽度超过
maxWidth时回溯找到合法的断点 - 断点规则:
space段消费后断行、soft-hyphen处可选择断行、glue(不间断)段不可断、zero-width-break处零宽断行、tab段跳跃到下一个 tab stop、hard-break强制断行 - Overflow 处理:当单个字形超过行宽时,使用预计算的字形级
breakableFitAdvances在字形边界做紧急断行 - 行尾字符处理:
letter-spacing在行尾正确扣除最后一个字形的额外间距 - 四种输出模式:
countPreparedLines(仅计数)、walkPreparedLinesRaw(行范围回调)、stepPreparedLineGeometry(单步迭代)、measurePreparedLineGeometry(几何测量)
内部结构图 (plainText):
+---------------------------+ +---------------------------+
| PreparedLineBreakData | | getEngineProfile() |
| (预编译的段级数据) | | (引擎特征,决定容差/策略) |
+-----------+---------------+ +---------------------------+
|
v
+---------------------------+
| 行行走主循环 |
| (逐段累加宽度) |
+-----------+---------------+
|
+-- 宽度 < maxWidth? --> 继续累加
+-- 宽度 > maxWidth? --> 回溯找断点
| +-- space/zero-width-break: 在段前断行
| +-- soft-hyphen: 可选择在段前/段内断行
| +-- 普通文本: 在段内字形边界断行
| +-- glue (不间断): 强制推进 (overflow)
| +-- hard-break: 强制在段后断行
|
v
+-----------+---------------+
| 四种输出模式 |
| - countPreparedLines | --> { lineCount }
| - walkPreparedLinesRaw | --> callback(每行范围)
| - stepPreparedLineGeometry | --> 单行范围 (迭代器模式)
| - measurePreparedLineGeometry | --> { lineCount, maxWidth }
+---------------------------+模块四:核心聚合层 (layout.ts)
设计说明:
layout.ts是库的公共 API 提供者。它本身不实现核心算法,而是协调analysis.ts、measurement.ts、bidi.ts、line-break.ts、line-text.ts五个模块。核心设计要点:prepare()/prepareWithSegments()的内部流程被拆分为analyzeText()(文本分析阶段)和测量阶段,保持接缝清晰layout()是 resize 热路径:直接调用countPreparedLines(),无 DOM 读、无 Canvas 调用、无字符串操作layoutWithLines()先遍历行范围,再调用buildLineTextFromRange()逐行构建文本walkLineRanges()和measureLineStats()** 不构建字符串,避免分配,适合 shrinkwrap 计算layoutNextLineRange()/materializeLineRange()提供迭代器式 API,支持可变行宽的手工布局- Locale 管理:
setLocale()重置 Word Segmenter 和所有缓存
内部结构图 (plainText):
+===========================================================+
| layout.ts |
| |
| prepare(text, font, opts?) |
| +-- analyzeText(text, opts) [analysis.ts] |
| +-- for each segment: |
| | getSegmentMetrics(seg, cache) [measurement.ts] |
| +-- computeSegmentLevels() [bidi.ts] (rich) |
| +-- return PreparedText / PreparedTextWithSegments |
| |
| layout(prepared, w, lh) |
| +-- countPreparedLines(prepared, w) [line-break.ts] |
| +-- return { height: lc * lh, lineCount } |
| |
| layoutWithLines(prepared, w, lh) |
| +-- walkPreparedLinesRaw(prepared, w, cb) |
| +-- for each emitted line: |
| | buildLineTextFromRange(...) [line-text.ts] |
| +-- return { height, lineCount, lines } |
+===========================================================+模块五:富文本内联辅助 (rich-inline.ts)
设计说明:
rich-inline.ts是一个独立的辅助模块,提供窄范围的内联富文本布局。它不处理块级布局、嵌套标记树、或通用 CSS 内联格式化。核心功能:- 跨项空白折叠:模拟浏览器的
white-space: normal空白折叠行为(相邻项之间的边界空白折叠) - 原子项:
break: 'never'的项(如 chip、mention)不会被拆分到多行 - 额外宽度:
extraWidth允许调用者添加 padding/border 等额外水平空间 - 行游标:使用
RichInlineCursor(包含itemIndex、segmentIndex、graphemeIndex)精确定位
- 跨项空白折叠:模拟浏览器的
内部结构图 (plainText):
+---------------------------+
| prepareRichInline(items[]) |
| - 逐项调用 prepareWithSegments()
| - 计算自然宽度 (带 extraWidth)
| - 返回 PreparedRichInline
+-----------+---------------+
|
v
+---------------------------+
| walkRichInlineLineRanges() |
| - 逐行遍历
| - 跨项空白折叠
| - 原子项保持完整
| - 每行回调 RichInlineLineRange
+-----------+---------------+
|
v
+---------------------------+
| materializeRichInlineLineRange()
| - 将行范围转为完整文本
| - 含 gapBefore, occupiedWidth
+---------------------------+5. 关键数据流程
场景一:快速高度测量 (prepare + layout)
场景说明:用户调用
prepare()预计算文本数据,然后多次调用layout()在不同宽度下计算高度。这是 Pretext 最高频的使用模式。流转时序图 (Mermaid):
场景二:手工逐行布局 + 可变行宽 (layoutNextLineRange + materializeLineRange)
场景说明:用户需要将文本精确分配到各行,每行宽度可能不同(例如浮动图片绕排)。使用迭代器式 API 逐行提取。
流转时序图 (Mermaid):
场景三:多行 Shrinkwrap (walkLineRanges)
场景说明:用户需要在不知道宽度的情况下,找到刚好容纳所有文本的最小宽度(shrinkwrap)。通过
walkLineRanges()不构建文本字符串地遍历所有行,统计最宽行。流转时序图 (Mermaid):
6. 接口与契约规范
6.1 核心类型定义
/**
* ============================================
* analysis.ts - 文本分析引擎契约
* ============================================
*/
/** 空白处理模式 */
export type WhiteSpaceMode = 'normal' | 'pre-wrap'
/** 断词模式 */
export type WordBreakMode = 'normal' | 'keep-all'
/** 段断行行为分类(8 种) */
export type SegmentBreakKind =
| 'text' // 普通文本段(可断行)
| 'space' // 可折叠空格(white-space: normal 下)
| 'preserved-space' // 保留空格(pre-wrap 下)
| 'tab' // 制表符
| 'glue' // 不间断胶合(NBSP/NNBSP/WJ 类)
| 'zero-width-break' // 零宽断行机会(ZWSP)
| 'soft-hyphen' // 软连字符(可选断行点)
| 'hard-break' // 硬换行(\n)
/** 文本分析产物 */
export type TextAnalysis = {
normalized: string // 规范化后的文本
chunks: AnalysisChunk[] // 硬换行分块信息
len: number // 段数量
texts: string[] // 各段文本
isWordLike: boolean[] // 各段是否为词类型
kinds: SegmentBreakKind[] // 各段断行类型
starts: number[] // 各段在规范化文本中的起始偏移
}
/**
* ============================================
* measurement.ts - Canvas 测量引擎契约
* ============================================
*/
/** 段的 Canvas 测量结果 */
export type SegmentMetrics = {
width: number // 段宽(含 Emoji 修正)
containsCJK: boolean // 是否含 CJK 字符
emojiCount?: number // Emoji 数量
breakableFitMode?: BreakableFitMode // 字形级宽度计算策略
breakableFitAdvances?: number[] | null // 字形级累积宽度
}
/** 浏览器引擎特征 */
export type EngineProfile = {
lineFitEpsilon: number // 行适配容差
carryCJKAfterClosingQuote: boolean // CJK 引号后处理
breakKeepAllAfterPunctuation: boolean // keep-all 下标点后断行
preferPrefixWidthsForBreakableRuns: boolean // Safari 前缀宽度策略
preferEarlySoftHyphenBreak: boolean // Chrome 软连字符策略
}
/** 字形级宽度计算策略 */
export type BreakableFitMode = 'sum-graphemes' | 'segment-prefixes' | 'pair-context'
/**
* ============================================
* layout.ts - 核心公共 API 契约
* ============================================
*/
/** 准备选项 */
export type PrepareOptions = {
whiteSpace?: WhiteSpaceMode
wordBreak?: WordBreakMode
letterSpacing?: number // CSS px 值
}
/** 布局游标(双级定位) */
export type LayoutCursor = {
segmentIndex: number // 段索引
graphemeIndex: number // 段内字形索引
}
/** 布局结果(快速路径) */
export type LayoutResult = {
height: number
lineCount: number
}
/** 行统计(无字符串分配) */
export type LineStats = {
lineCount: number
maxLineWidth: number
}
/** 布局行(含文本内容) */
export type LayoutLine = {
text: string
width: number
start: LayoutCursor
end: LayoutCursor
}
/** 布局行范围(无文本内容,更轻量) */
export type LayoutLineRange = {
width: number
start: LayoutCursor
end: LayoutCursor
}
/** 布局结果(含行文本) */
export type LayoutLinesResult = LayoutResult & {
lines: LayoutLine[]
}
/**
* ============================================
* rich-inline.ts - 富文本内联辅助契约
* ============================================
*/
/** 富文本内联项 */
export type RichInlineItem = {
text: string // 原始文本(含前后空白)
font: string // Canvas 字体简写
letterSpacing?: number // 字形间额外间距
break?: 'normal' | 'never' // 'never' = 原子项(chip/mention)
extraWidth?: number // 额外水平 Chrome(padding/border)
}
/** 富文本内联游标 */
export type RichInlineCursor = {
itemIndex: number
segmentIndex: number
graphemeIndex: number
}6.2公共 API 函数签名汇总
/**
* 核心 API (layout.ts)
*/
// 准备阶段(快速路径 + 富文本路径)
function prepare(text: string, font: string, options?: PrepareOptions): PreparedText
function prepareWithSegments(text: string, font: string, options?: PrepareOptions): PreparedTextWithSegments
// 布局阶段(快速路径)
function layout(prepared: PreparedText, maxWidth: number, lineHeight: number): LayoutResult
// 布局阶段(富文本路径 - 批量)
function layoutWithLines(prepared: PreparedTextWithSegments, maxWidth: number, lineHeight: number): LayoutLinesResult
// 布局阶段(富文本路径 - 无字符串分配)
function walkLineRanges(prepared: PreparedTextWithSegments, maxWidth: number, onLine: (line: LayoutLineRange) => void): number
function measureLineStats(prepared: PreparedTextWithSegments, maxWidth: number): LineStats
function measureNaturalWidth(prepared: PreparedTextWithSegments): number
// 布局阶段(富文本路径 - 迭代器模式,可变行宽)
function layoutNextLine(prepared: PreparedTextWithSegments, start: LayoutCursor, maxWidth: number): LayoutLine | null
function layoutNextLineRange(prepared: PreparedTextWithSegments, start: LayoutCursor, maxWidth: number): LayoutLineRange | null
function materializeLineRange(prepared: PreparedTextWithSegments, line: LayoutLineRange): LayoutLine
// 缓存控制
function clearCache(): void
function setLocale(locale?: string): void
/**
* 富文本内联 API (rich-inline.ts)
*/
function prepareRichInline(items: RichInlineItem[]): PreparedRichInline
function layoutNextRichInlineLineRange(prepared: PreparedRichInline, maxWidth: number, start?: RichInlineCursor): RichInlineLineRange | null
function walkRichInlineLineRanges(prepared: PreparedRichInline, maxWidth: number, onLine: (line: RichInlineLineRange) => void): number
function materializeRichInlineLineRange(prepared: PreparedRichInline, line: RichInlineLineRange): RichInlineLine
function measureRichInlineStats(prepared: PreparedRichInline, maxWidth: number): RichInlineStats8. 快速开始
8.1 环境配置
# 安装依赖
bun install
# 启动开发服务器
bun start # → http://localhost:3000
# 运行类型检查 + 代码检查
bun run check # tsc + oxlint + knip
# 运行测试
bun test8.2 安装与使用
npm install @chenglou/pretextimport { prepare, layout } from '@chenglou/pretext'
const prepared = prepare('AGI 春天到了. بدأت الرحلة 🚀', '16px Inter')
const { height, lineCount } = layout(prepared, 320, 20)
// height 和 lineCount 精确匹配浏览器行为8.3 典型用例
| 用例 | 推荐 API |
|---|---|
| 虚拟滚动高度预测 | prepare() + layout() |
| Canvas 自定义渲染 | prepareWithSegments() + layoutWithLines() |
| 浮动图片绕排 | prepareWithSegments() + layoutNextLineRange() + materializeLineRange() |
| 多行 shrinkwrap | prepareWithSegments() + walkLineRanges() |
| 内联富文本 (chip/mention) | @chenglou/pretext/rich-inline |