验证文本排版:语料库、浏览器扫描与大规模精度校验
前置知识
- ›第 1 篇:架构设计与两阶段模型
- ›熟悉测试概念与 CI/CD
- ›了解跨浏览器差异的基本知识
验证文本排版:语料库、浏览器扫描与大规模精度校验
如何确认一个文本排版库输出的结果是正确的?对 Pretext 而言,"正确"意味着"与浏览器的原生排版一致"——在每个容器宽度下,行数、高度、换行位置都必须完全吻合。这一要求覆盖 Chrome、Safari 和 Firefox,覆盖英语、阿拉伯语、中文、日语、韩语、高棉语、缅甸语、乌尔都语、希伯来语以及混合文字,覆盖 10px 到 28px 的各种字号,以及 1 到 900 之间每一个整数像素宽度。
这是一个极具挑战性的正确性目标,Pretext 也为此建立了一套异常完善的验证体系。本系列最后一篇文章将聚焦该体系的三大支柱:基于伪 Canvas 的确定性单元测试、自动化的浏览器精度扫描,以及带有错误分类体系的多语言语料库验证。
单元测试:确定性的伪 Canvas
layout.test.ts 中的单元测试套件采用了一种务实的方式来解决测试依赖 Canvas 的核心难题:用一个确定性的宽度函数替换 measureText()。
文件开头的注释清晰地传达了设计思路:
// Keep the permanent suite small and durable. These tests exercise the shipped
// prepare/layout exports with a deterministic fake canvas backend.
伪 measureWidth() 函数根据字符类型分配确定性宽度:
function measureWidth(text, font) {
const fontSize = parseFontSize(font)
let width = 0
for (const ch of text) {
if (ch === ' ') width += fontSize * 0.33
else if (ch === '\t') width += fontSize * 1.32
else if (isEmoji(ch)) width += fontSize
else if (isWideChar(ch)) width += fontSize // CJK
else if (isPunctuation(ch)) width += fontSize * 0.4
else width += fontSize * 0.6
}
return width
}
这样一来,测试无需依赖任何字体引擎就能获得可复现的宽度。真实的 Intl.Segmenter 依然用于文本分析,只有测量后端被替换掉了。这意味着测试覆盖了完整的分析流程(分词、合并级联、断行类型分类),同时保证换行行为完全可预测。
flowchart TD
A[Test input text] --> B[Real Intl.Segmenter]
B --> C[Real analysis pipeline]
C --> D[Fake Canvas measureText]
D --> E[Real line walker]
E --> F[Deterministic results]
style D fill:#ff9,stroke:#333
测试套件验证了以下几类不变式:
- 重建性:三个富 API(
layoutWithLines、walkLineRanges、layoutNextLine)产生的行能还原回原始规范化文本 - 游标单调性:行尾游标严格递增
- API 一致性:
layout()与layoutWithLines()的行数结果一致 - 流式等价性:在固定宽度输入下,
layoutNextLine()与layoutWithLines()产生相同的行 - 可变宽度流式处理:使用逐行宽度数组的
layoutNextLine()能产生合法且可重建的输出
reconstructFromLineBoundaries() 和 collectStreamedLines() 是实现这些验证的核心工具:
提示: "小而稳健"的测试哲学意味着,新的浏览器特定边界情况应通过一次性探针脚本和浏览器检查脚本来排查,而不是不断扩充永久测试套件。只有稳定的不变式才值得沉淀为永久测试。
浏览器精度扫描
单元测试验证的是内部一致性,浏览器精度扫描验证的是外部正确性——Pretext 的输出是否真的与浏览器的实际排版一致?
accuracy-check.ts 脚本自动化了这一比对过程:
scripts/accuracy-check.ts#L1-L14
扫描流程如下:
- 启动一个临时的本地 Bun 服务器,提供精度测试页面
- 通过自动化工具启动浏览器(Chrome、Safari 或 Firefox)
- 针对每个测试用例,在不同容器宽度下测量 DOM 文本的实际高度
- 与 Pretext 预测的行数进行比对
- 输出包含逐行诊断信息的错误报告
结果以 JSON 快照的形式提交到代码库:
| 文件 | 内容 |
|---|---|
accuracy/chrome.json |
Chrome 的完整精度数据 |
accuracy/safari.json |
Safari 的完整精度数据 |
accuracy/firefox.json |
Firefox 的完整精度数据 |
status/dashboard.json |
机器可读的聚合看板数据 |
flowchart TD
A["accuracy-check.ts"] --> B["Start Bun server"]
B --> C["Launch browser via automation"]
C --> D["Navigate to accuracy page"]
D --> E["For each test case × width"]
E --> F["DOM: measure actual height"]
E --> G["Pretext: layout() predicted height"]
F --> H{Match?}
G --> H
H -->|yes| I[Record match]
H -->|no| J[Record mismatch with diagnostics]
I --> K["Write accuracy/*.json"]
J --> K
K --> L["Update status/dashboard.json"]
错误报告中的逐行诊断信息对调试至关重要——它能精确指出浏览器与 Pretext 在哪一行产生了不同的换行决策,让问题排查有据可依,而不是凭空猜测。
各浏览器分别独立运行扫描,因为浏览器特定的标志位(如第 4 篇所述)会产生有意为之的差异结果。Chrome 的错误可能源于 carryCJKAfterClosingQuote,Safari 的错误可能源于 preferPrefixWidthsForBreakableRuns。
多语言语料库验证
除了精心策划的精度测试用例外,Pretext 还通过语料库系统对真实世界的多语言文本进行验证:
scripts/corpus-sweep.ts#L1-L11
语料库系统包含:
- 数据源:阿拉伯语、中文、日语、韩语、高棉语、缅甸语、乌尔都语、希伯来语以及混合文字的真实文本
- 代表性金丝雀:一批精选文本(
corpora/representative.json),能够覆盖各文字体系的特定边界情况 - 扫描快照:在采样宽度和以 10px 为步长的细粒度宽度下提交的扫描结果
其核心方法论是粗扫描、精诊断:
- 扫描:在一系列容器宽度(如 300–900px,步长 10px)下运行所有语料库文本。由于
layout()本身很快,这一步开销极低。 - 识别:找出预测行数与实际行数不一致的宽度。
- 诊断:仅针对不匹配的宽度,运行代价较高的逐行诊断比对,以定位具体的换行差异。
RESEARCH.md 文件记录了各个脚本当前的整体进展:
- Japanese: two real canaries (羅生門, 蜘蛛の糸), both clean at anchor widths
- Chinese: two long-form canaries (祝福, 故鄉) with real font sensitivity
- Myanmar: two canaries with residual Chrome/Safari disagreement
- Urdu: Nastaliq/Naskh canary with narrow-width negative field
- Arabic: coarse corpora are clean; remaining work is fine-width edge-fit
分类系统(scripts/corpus-taxonomy.ts)将错误归入不同类别,帮助区分:
- 预处理问题:分析流水线的分词结果与浏览器不一致
- 边界拟合问题:浮点数累积导致某个片段恰好超出行边界
- 字体敏感性问题:相同文本和宽度下,不同字体产生不同结果
- 浏览器特定行为:某个浏览器的表现与其他浏览器不同
构建、发布与状态看板
Pretext 通过 tsc 以最简构建配置输出 ESM:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"allowImportingTsExtensions": false,
"rootDir": "./src",
"outDir": "./dist",
"declaration": true
},
"include": ["src/**/*.ts"],
"exclude": ["src/layout.test.ts", "src/test-data.ts"]
}
源码中 import 使用 .js 扩展名的约定(如 import { analyzeText } from './analysis.js'),使得直接用 tsc 编译即可输出正确的 JavaScript 和 .d.ts 文件,无需额外的声明文件重写步骤。这是经过深思熟虑的选择——构建链中没有 Webpack、没有 Rollup、也没有 Vite。
package.json 的 exports 映射将 "." 指向 ./dist/layout.js,TypeScript 类型指向 ./dist/layout.d.ts。冒烟测试脚本(scripts/package-smoke-test.ts)会验证打包产物对 JS 和 TS 消费者都能正常工作,在发布前捕获类型声明缺失或导出路径错误等问题。
状态看板(status/dashboard.json)将已提交的精度和性能基准快照汇总为机器可读的摘要,作为当前库精度与性能状态的唯一可信来源。
提示: 如果你要为 Pretext 贡献代码,请务必在修改前后比对
accuracy/和corpora/中已提交的快照。AGENTS.md文件提供了何时重新生成快照以及如何解读错误的完整说明。
RESEARCH.md:项目的知识沉淀
代码库中最与众不同的文件也许是 RESEARCH.md——这是一份研究日志,记录了构建这个库过程中所有的尝试、度量与发现:
# Research Log
Everything we tried, measured, and learned while building this library.
其中记录了:
- 被否定的方案:在热路径中使用 DOM 测量、SVG
getComputedTextLength()、在排版过程中进行字符串重建——这些方案都经过实验,并附有明确的否定理由 - 持久性发现:macOS 上 Canvas 与 DOM 在
system-ui字体解析上的不一致,CanvasmeasureText()逐词累加的精度特性 - 设计决策:为何
layout()必须保持纯算术运算,为何 bidi 层级在富 API 路径上仅作为元数据 - 文字体系专项说明:各文字体系的当前精度状态,以及尚未解决的问题
这是最有价值的项目知识沉淀形式。新贡献者读完 RESEARCH.md,不仅能了解构建了什么,还能知道哪些路走不通——避免耗费数月时间重蹈覆辙。
AGENTS.md 则从实现层面为贡献者提供补充说明:
核心原则包括:
- 保持
layout()快速且低内存分配 - 将文字体系特定的修复放在预处理阶段,而非 line walker 中
- 精度页面在所有三个浏览器的新运行中应全部通过
- 优先使用一次性探针,而非扩充永久测试套件
- 先以低成本扫描各宽度,再对不匹配的宽度做精细诊断
系列总结
在这六篇文章中,我们完整追溯了 Pretext 的全貌:从最初的动机(DOM 排版抖动)到架构答案(两阶段 prepare/layout),从最深层的内部实现(合并级联、换行引擎、浏览器垫片),到面向消费者的 API(自适应包裹、障碍物绕排、编辑排版),最终到验证基础设施(伪 Canvas 测试、浏览器扫描、多语言语料库)。
这套设计有着鲜明的主见:测量只发生一次,排版是纯算术运算,公开句柄不透明,浏览器差异通过显式标志位而非特性检测来处理。这些主张都有大量实证验证作为支撑——三个浏览器、十余种文字体系的已提交精度快照,充分证明了这套方案在实践中的可靠性,而不只是停留在理论层面。
对于一个约 3200 行的库(不含测试和 demo),Pretext 承载了相当丰富的文本排版技术。代码库值得深入细读——尤其是 analysis.ts 中的合并级联架构、line-break.ts 中的简单/完整 walker 调度机制,以及 measurement.ts 中优雅处理跨浏览器 emoji 宽度的缓存与校正方案。