Figma 文本测量实验
本文记录 @typeset/figma-plugin-typeset 在 Figma rendering pipeline 中进行的 measurement 实验、原始数据、推导过程和已知限制,供后续实现考证。当前正式 adapter 与 plugin 工作流程见包目录下的 README.md。
实验记录
以下结果记录于 2026-07-11。实验均使用 uniform-style、左对齐、固定宽度或 auto-height 的 TextNode。Plugin 将每个唯一 token 克隆为 WIDTH_AND_HEIGHT TextNode,以 probe.width 作为 token advance,再比较 token width 之和与同一段文字由 Figma 整体渲染后的宽度。
初始混排实验
| Font | Size | Text | Predicted | Figma line | Delta |
|---|---|---|---|---|---|
| OPPO Sans 4.0 Bold | 12px | 测试1234测试100%高度 |
134px | 131px | -3px |
| OPPO Sans 4.0 Regular | 12px | 100%完成、37℃测试、5‰增长、90°旋转😄 |
245px | 239px | -6px |
| Inter Regular | 12px | 100%完成、37℃测试、5‰增长、90°旋转😄 |
247px | 244px | -3px |
这些结果首先证明了孤立 token 的 TextNode width 不能保证具有可加性,但尚不能单独区分 width quantization、cross-token shaping 与 font fallback。
PingFang SC 长段落
PingFang SC Regular 16px、容器宽度 348px 的中英文长段落得到以下 line delta:
+10, 0, 0, +4, 0, 0, -2, +5, 0, +4, +6, 0 px
正误差所在行均包含 space;不含 space 的纯 CJK 行大多为 0px。唯一的 space probe 为 0px,但 space 在完整 line 中具有实际 advance。该实验首先暴露了 whitespace 不能通过孤立 auto-width TextNode 测量。
字体与字号对照
测试文本包含 AV、To、Wa、Latin repetition、100%、CJK/Latin、CJK/number 和一至两个 spaces。下表只列出非零 line delta;未列出的无空格测试行均为 0px。
| Font | Size | Whitespace delta | 其他 delta |
|---|---|---|---|
| PingFang SC Regular | 16px | A B +5px;A B +10px |
无 |
| OPPO Sans 4.0 Regular | 16px | A B +4px;A B +7px |
CJK 与 Latin/number/punctuation 的若干组合 -1px |
| Inter Regular | 16px | A B +4px;A B +9px |
无 |
| PingFang SC Regular | 12px | A B +4px;A B +7px |
100% 及对应 CJK mixed line -1px |
| OPPO Sans 4.0 Regular | 12px | A B +2px;A B +4px |
部分 CJK/Latin 与 CJK/number line -1px |
| Inter Regular | 12px | A B +3px;A B +6px |
无 |
所有测试字体中,孤立的 " " 与 " " probe width 都是 0px。因此 whitespace measurement 是确定存在的问题,而不是特定字体的 OpenType 行为。
Token 内部 shaping
Latin pair 显示字体 shaping 确实存在:
| Font / size | A |
V |
A + V |
AV |
|---|---|---|---|---|
| PingFang SC 16px | 11px | 11px | 22px | 21px |
| OPPO Sans 4.0 16px | 11px | 11px | 22px | 20px |
| Inter 16px | 11px | 11px | 22px | 21px |
当前 tokenizer 会把连续 Latin word 作为一个 token,所以 AV、To、fi、ffi、ffl、office 与 affinity 的内部 kerning/ligature 已包含在 token probe 中。这些测试行的 predicted width 与 Figma line width 一致。->、100% 等跨 token pair 在部分字体/字号中一致,部分情况下相差 1px;尚不能据此断定具体 OpenType feature。
Figma 的 openTypeFeatures 只报告偏离字体默认值的设置;没有手动开启 feature 不代表字体默认 shaping 未生效。尚未完成逐项切换 KERN、LIGA、CALT、numeral feature 与 variable font optical size 的对照实验。
Width quantization
Latin repetition 的 auto-width probe:
| Font / size | A |
AA |
AAAA |
AAAAAAAA |
|---|---|---|---|---|
| PingFang SC 16px | 11px | 22px | 43px | 85px |
| OPPO Sans 4.0 16px | 11px | 22px | 43px | 86px |
| Inter 16px | 11px | 22px | 44px | 87px |
| PingFang SC 12px | 8px | 16px | 32px | 64px |
| OPPO Sans 4.0 12px | 9px | 17px | 33px | 65px |
| Inter 12px | 9px | 17px | 33px | 65px |
重复 OPPO Sans 4.0 Regular 16px 的 CJK character 测 得到更直接的累计误差:
| Count | Token sum | Figma line | Delta |
|---|---|---|---|
| 1 | 16px | 16px | 0px |
| 2 | 32px | 32px | 0px |
| 4 | 64px | 64px | 0px |
| 8 | 128px | 127px | -1px |
| 16 | 256px | 253px | -3px |
该序列强烈支持以下模型:Figma 对完整 TextNode 的实际 advance 统一量化,而当前实现先量化每个孤立 token,再将量化后的整数相加。
Figma line: ceil(a₁ + a₂ + ... + aₙ)
Current adapter: ceil(a₁) + ceil(a₂) + ... + ceil(aₙ)
若暂按向上取整解释,单个 测 的真实 advance 满足 15.75px < advance <= 15.8125px,约为 15.8px。具体 rounding mode 与未量化 advance 尚未由公开 API 直接证实。
Bounds 与 SVG 实验
Validation board 现已同时记录以下数据,并为 token 与完整 rendered line 导出 outline SVG:
node.width
node.absoluteBoundingBox?.width
node.absoluteRenderBounds?.width
SVG root width / height / viewBox
重新导入 SVG 后的 width / height
Outline SVG 会重新导入并保留在画布上。该实验用于确认 Figma 内部是否仍保留亚像素 glyph geometry。结果尚待收集。absoluteRenderBounds 与 SVG outline 描述 visual ink bounds,不应在未经验证时直接视为 typographic advance;不可见 whitespace 和末尾 glyph side bearing 仍可能缺失。
OPPO Sans 4.0 Regular 16px 的重复 测 实验已经得到第一组结果:
| Count | Layout width | Bounding width | Render width | SVG root/imported width |
|---|---|---|---|---|
| 1 | 16px | 16px | 13.599998px | 14px |
| 2 | 32px | 32px | 29.381250px | 30px |
| 4 | 64px | 64px | 60.943748px | 61px |
| 8 | 127px | 127px | 124.068748px | 125px |
| 16 | 253px | 253px | 250.318756px | 251px |
absoluteBoundingBox.width 与 node.width 完全相同,没有提供额外精度。absoluteRenderBounds.width 保留了亚像素 visual geometry;从重复序列中消去首尾 ink bearing 后,得到几乎恒定的 glyph origin distance:
render(2) - render(1) = 15.781252px
(render(4) - render(2)) / 2 = 15.781249px
(render(8) - render(4)) / 4 = 15.781250px
(render(16) - render(8)) / 8 = 15.781251px
因此该字体下 测 的实际 advance 可以确定为约 15.78125px,即 0.986328125em。Figma layout width 与以下模型完全吻合:
layoutWidth(n × 测) = ceil(n × 15.78125px)
单字 absoluteRenderBounds.width 为 13.6px,它表示 glyph ink width,不是 15.78125px 的 advance,因而不能直接替代 token measurement。重复 run 的 render width 差值能够消除相同首尾 glyph 的 bearings,但这一方法对 arbitrary token、cross-token shaping 和 whitespace 仍需要额外验证。
Outline SVG root width 与重新导入后的 width 均等于 render width 向上取整,没有比 absoluteRenderBounds 提供更多根级精度。SVG path 内部坐标是否保留更多 origin/outline 信息尚未检查。
Guard differential 实验
使用 OPPO Sans 4.0 Regular 16px,以相同的 CJK glyph 测 作为左右 guard,并用 render("测测") = 29.381250px 作为基线:
| Run | Render width | 与 测测 的差值 |
|---|---|---|
测测 |
29.381250px | 0px |
测 测 |
32.912498px | 3.531248px |
测 测 |
36.443748px | 7.062498px |
测A测 |
40.099998px | 10.718748px |
测AA测 |
50.818748px | 21.437498px |
测100测 |
55.349998px | 25.968748px |
测100%测 |
69.849998px | 40.468748px |
因为两个 run 的首尾 glyph 相同,visual ink bearings 在差值中被消去。该实验得到以下 contextual advances:
space = 3.53125px
A = 10.71875px
AA = 21.43750px
100 = 25.96875px
100% = 40.46875px
% = 14.50000px // 100% - 100
用这些未量化 advance 求和后再统一向上取整,可以精确复现本次全部 Figma line width:
测测 ceil(31.56250) = 32px
测 测 ceil(35.09375) = 36px
测 测 ceil(38.62500) = 39px
测A测 ceil(42.28125) = 43px
测AA测 ceil(53.00000) = 53px
测100测 ceil(57.53125) = 58px
测100%测 ceil(72.03125) = 73px
这证明 fixed visible guards 配合 absoluteRenderBounds 有可能恢复 whitespace 与 arbitrary token 的亚像素 contextual advance。但是差值包含 guard-token 和 token-guard 的 shaping,相同 token 换用不同 guard 是否仍得到相同结果尚未验证。在完成 guard independence 实验前,不应把该方法作为正式 Figma measurement adapter。
随后使用 测、试、田 三个 ink bounds 明显不同的 CJK glyph 重复实验:
| Guard | Base render | Space delta | A delta |
100 delta |
|---|---|---|---|---|
测 |
29.381250px | 3.531248px | 10.718748px | 25.968748px |
试 |
30.341251px | 3.531250px | 10.718750px | 25.968750px |
田 |
27.573250px | 3.531250px | 10.718750px | 25.968750px |
三种 guard 得到的 token advance 在 Figma 暴露的精度内完全一致。这确认了相同首尾 guard 能稳定消去不同 glyph 的 visual bearings,且本组 CJK guard 没有对 space、A 或 100 引入可观察的 guard-specific shaping。
当前可以将候选测量公式写为:
advance(token, guard) = render(guard + token + guard) - render(guard + guard)
该公式已经通过 guard independence,但尚未证明两个分别测得的 token advance 在所有相邻组合中都可直接相加。下一项风险是 token-token kerning、ligature 或 contextual shaping;必要时可以进一步测量 guarded token pair,并将差值表示为自然 shaping correction:
pairCorrection(A, B)
= advance(A + B, guard) - advance(A, guard) - advance(B, guard)
使用 测 guard 且开启 Kerning pairs 的 token pair 实验得到:
| Token/run | Guarded advance |
|---|---|
A |
10.718748px |
V |
10.359373px |
AV |
19.703123px |
100 |
25.968748px |
% |
14.499998px |
100% |
40.468748px |
- |
5.937498px |
> |
9.921873px |
-> |
15.859373px |
| space | 3.531248px |
B |
9.859373px |
A B |
24.109373px |
由此得到:
pairCorrection(A, V) = -1.374998px
pairCorrection(100, %) ≈ 0px
pairCorrection(-, >) ≈ 0px
correction(A, space, B) ≈ 0px
AV 的 pair shaping 明确存在,但当前 tokenizer 会把连续 Latin word AV 作为一个 token,因此 guarded AV measurement 已经包含该 shaping,不需要在 token boundary 再次修正。当前实际跨 token 的 100|%、-|> 和 A|space|B 在本次字体与字号下均可加。
随后关闭 Kerning pairs,其他设置保持不变。A 与 V 的 guarded advance 没有变化,而 AV 发生以下变化:
| Kerning pairs | A |
V |
AV |
Pair correction |
|---|---|---|---|---|
| Enabled | 10.718748px | 10.359373px | 19.703123px | -1.374998px |
| Disabled | 10.718748px | 10.359373px | 21.078123px | ≈ 0px |
关闭 Kerning pairs 后,孤立 AV probe 的 layout width 也从 20px 变为 22px,guarded 测AV测 的 layout width 从 52px 变为 53px。其他已测试组合的 render width 与 correction 均未变化。
该对照确认先前 AV 的 -1.375px correction 来自 KERN,而不是 measurement noise。由于 measurement probe 是从 source TextNode clone 得到的,guarded whole-token measurement 会自动继承并包含 source 的 OpenType shaping。未来若缓存 Figma measurement,cache key 不能只有 token text,还必须包含 font、size、OpenType features、letter spacing 及其他会影响 shaping 的 text style。
后续又完成两组 feature-enabled regression:
| Font | Enabled feature | Lines | Non-zero layout delta |
|---|---|---|---|
| PingFang SC Regular 16px | Ligatures | 2 | 0 |
| OPPO Sans 4.0 Regular 16px | Kerning pairs | 2 | 0 |
这些 feature 会改变真实 glyph shaping 和 token advance,但不会降低 guarded measurement 的准确性,因为 isolated、guarded probe 与最终 line 都 clone 同一个 source TextNode 并继承相同 feature。OPPO Sans 的同字体 KERN on/off 对照已经直接证明 AV advance 改变 1.375px,而两种状态下 line layout 都能被正确预测。
PingFang Ligatures 与 OPPO Kerning 使用不同字体,因此不能将两份结果之间的 token 数值差直接归因于某个 feature。若要量化 LIGA 对 office、fi、ffi 的具体影响,仍需在同一字体、字号和文本下进行 on/off 对照;但就“feature 开启是否导致 measurement 失准”而言,现有结果已经给出否定答案。
这组结果支持保留 core 的最小 additive MeasureToken interface,并在 Figma adapter 内将 measurement source 从孤立 TextNode.width 改为 guarded absoluteRenderBounds differential。该结论仍限于目前测试过的 CJK/Latin/number/symbol/whitespace 组合;不同字体、复杂 script 或更长 contextual substitution 仍可能要求 pair/run correction。
Guarded measurement 接入回归
Validation demo 改用固定 田 guard 的 render-bounds differential 后,OPPO Sans 4.0 Regular 16px 完成以下回归:
| Case | Container | Lines | Coverage | Non-zero layout delta |
|---|---|---|---|---|
| Latin/CJK/token matrix,Kerning pairs enabled | 348px | 22 | Latin word、kerning、space、CJK、number、symbol | 0 |
| 中英文长段落 | 348px | 11 | 126 unique tokens、space、punctuation、quotes、multiple Latin words | 0 |
| 数字符号窄栏混排 | 157px | 3 | %、℃、‰、°、emoji、CJK punctuation |
0 |
| Pair matrix,Kerning pairs disabled | 348px | 13 | AV、100%、->、A B |
0 |
| 初始短文本窄栏 | 127px | 2 | 测试1234测试100%高度 |
0 |
| OPPO Sans 4.0 Bold 12px | 79px | 2 | 初始短文本、字重与字号变化 | 0 |
| PingFang SC Regular 12px | 157px | 2 | CJK、number、symbol、emoji | 0 |
合计 55 条 rendered line,所有 line 均满足:
ceil(sum(guarded token advances)) === Figma TextNode layout width
代表结果包括:
测试1234测试 predicted advance 96.796883px ceil 97px Figma 97px
100%高度 predicted advance 72.031250px ceil 73px Figma 73px
100%完成、37℃测 ceil 137px Figma 137px
试、5‰增长、90°旋 ceil 150px Figma 150px
转😄 ceil 32px Figma 32px
126-token 长段落的 11 条 line 也全部为 layout delta = 0。其中包含 leading space、多个 Latin word 和大量 CJK token,说明 guarded advances 在较长求和路径中没有重新出现孤立 width 的累计取整误差。
Guarded 回归现已覆盖 OPPO Sans 4.0 Regular 16px、OPPO Sans 4.0 Bold 12px 和 PingFang SC Regular 12px,因此字体、字号与字重的基本变化已经得到验证。Latin font 下的 CJK fallback、non-zero letter spacing、variable font、mixed styles 与复杂 grapheme/script 尚未完成同等回归,因此目前可以将该方法视为 Figma plugin 内部已验证的技术实现,但还不能宣称为所有 text style 的最终 measurement contract。
Non-zero letter spacing
OPPO Sans 4.0 Regular 16px、文本 测试A B测试100% 的 targeted regression:
| Letter spacing | Predicted advance | Predicted ceil | Figma layout | Layout delta |
|---|---|---|---|---|
| 1px | 138.703125px | 139px | 138px | -1px |
| 20%(3.2px) | 162.903126px | 163px | 160px | -3px |
Guarded differential 对包含 k 个 grapheme 的 token 额外计入 k 个 letter-spacing gaps;将 token advances 相加后,整行比 Figma 实际的 graphemeCount - 1 个 gaps 多出一个 trailing letter spacing。两次实验均符合:
actual continuous line advance
= sum(guarded token advances) - letterSpacing
因此 non-zero letter spacing 不能完全封装在 additive token measurement 中。正式实现需要将 token 内部 spacing、token boundary spacing 与 line-end omission 分开表达;在该模型实现前,Figma adapter 应显式限制 letterSpacing = 0,而不是静默接受并产生系统误差。
Latin font 与 CJK fallback
Inter Regular 16px、固定 CJK guard 田、关闭 OpenType glyph features 的 mixed regression:
| Line | Content | Predicted ceil | Figma layout | Layout delta |
|---|---|---|---|---|
| 1 | AVATAR office 100% -> |
179px | 177px | -2px |
| 2 | A B |
31px | 31px | 0px |
| 3 | 测试Inter混排😄é 👨👩👧👦 🇨🇳 |
170px | 165px | -5px |
| 4 | が ガ 𠮷野家 |
89px | 89px | 0px |
Inter 不包含完整 CJK glyph,固定 田 guard 与 CJK token 会进入 fallback font run。Guarded token measurement 在这种 mixed fallback context 下不再保证可加,尤其是 Latin spaces、Latin/CJK/emoji run transition。该结果与 OpenType feature 无关,因为本次已关闭相关 glyph features。
第 4 行包含 combining dakuten 与 supplementary CJK code point 且精确匹配,因此当前数据不能证明 complex grapheme 本身存在 measurement 问题。后续 trailing-whitespace 实验进一步解释了第 3 行的全部误差,见下节。
固定 CJK guard 方法目前只对 CJK-capable font 得到完整验证。若要支持 Latin-primary font 与 fallback CJK,至少需要按 script/font run 选择 guard,且 whitespace 可能必须按实际相邻 run 测量;若这些局部 measurement 仍不可加,则需要退回连续 range/run measurement。
Trailing whitespace
随后使用 OPPO Sans 4.0 Regular 16px(CJK-capable font)复测同一组 Latin/CJK/emoji/complex-grapheme 文本。四条 line 中只有两条出现 delta,且两条 source line 均由字节检查确认以 0x20 space 结束:
| Line | Predicted advance | Trailing space | Corrected advance | Corrected ceil | Figma layout |
|---|---|---|---|---|---|
| 1 | 151.843765px | 3.531250px | 148.312515px | 149px | 149px |
| 3 | 141.562500px | 3.531250px | 138.031250px | 139px | 139px |
不以 space 结尾的 line 2 与 line 4 均为 layout delta = 0。Native/Typeset overlay 完全重合,因为 trailing whitespace 不产生 visible ink,Figma TextNode layout width 也不计入该 whitespace advance。
Inter regression 的 line 3 同样由字节检查确认以 space 结束:
169.484344px - 4.499998px = 164.984346px
ceil(164.984346px) = 165px = Figma layout
因此 Inter line 3 的 -5px 已完全由 trailing whitespace 解释;Inter 目前只剩不含 trailing space 的纯 Latin line 1 存在 -2px unresolved delta。Complex grapheme 在 CJK-capable font 下没有观察到独立误差,combining mark、ZWJ emoji、flag sequence、combining dakuten 与 supplementary CJK code point 均已得到正确 visual overlay。
Trailing whitespace 的 delta 来自 Figma TextNode 的 layout-box semantics,而不是 guarded token measurement 失败。Core 当前仍按 source token advance 计算该 whitespace;Figma adapter 在验证和渲染时应将这类 delta 解释为平台差异。本阶段不据此修改 core solver 或 layout output。
当前结论
- 孤立 auto-width TextNode 的
width不是可安全累加的 token advance。 - Whitespace 的孤立 probe 恒为
0px,必须使用上下文测量或其他 measurement source。 - Token 内部 shaping 已由完整 token probe 保留;误差主要发生在 token 之间及逐 token width quantization。
- Web/Pretext 的 additive token measurement 接口不能未经验证地直接套用于 Figma。
absoluteRenderBounds已确认保留亚像素 ink geometry,但不直接等于 advance;SVG root geometry 没有提供额外精度。- Fixed guard differential 已在 CJK/Latin/number/whitespace 样本中精确恢复 contextual advance,并通过三个 CJK guard 的 independence 实验。
- Pair shaping 在
AV内明确存在,但已被当前 Latin-word token 吸收;已测试的实际跨 token pair 均可加。 - Kerning pairs on/off 对照确认
AVcorrection 来自KERN;guarded whole-token measurement 能自动反映 source OpenType 设置。 - 当前结果支持保留 additive
MeasureTokeninterface,并由 Figma adapter 提供 guarded render-bounds measurement。 - OPPO Sans、PingFang SC、12px/16px、Regular/Bold 共 55 条 guarded regression line 全部得到
layout delta = 0。 - Non-zero letter spacing 会固定多计一个 line-end spacing;当前 adapter 必须限制为
letterSpacing = 0,直至 spacing 被独立建模。 - 固定 CJK guard 在 Inter 的纯 Latin line 中仍有 -2px unresolved delta,不能作为 Latin-primary font 的最终 measurement contract。
- Inter mixed line 的 -5px 已确认完全来自 trailing whitespace,而不是 complex grapheme。
- Trailing whitespace 属于 Figma TextNode 的已知 layout 差异,仅记录在本 plugin 文档中;当前不修改 core solver。
- Complex grapheme 在 CJK-capable font 下已通过;variable font、mixed styles 与其他复杂 script 仍未验证。