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 测量。

字体与字号对照

测试文本包含 AVToWa、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,所以 AVTofiffifflofficeaffinity 的内部 kerning/ligature 已包含在 token probe 中。这些测试行的 predicted width 与 Figma line width 一致。->100% 等跨 token pair 在部分字体/字号中一致,部分情况下相差 1px;尚不能据此断定具体 OpenType feature。

Figma 的 openTypeFeatures 只报告偏离字体默认值的设置;没有手动开启 feature 不代表字体默认 shaping 未生效。尚未完成逐项切换 KERNLIGACALT、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.widthnode.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.width13.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-tokentoken-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、A100 引入可观察的 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,其他设置保持不变。AV 的 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。若要量化 LIGAofficefiffi 的具体影响,仍需在同一字体、字号和文本下进行 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 AV100%->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。

当前结论

  1. 孤立 auto-width TextNode 的 width 不是可安全累加的 token advance。
  2. Whitespace 的孤立 probe 恒为 0px,必须使用上下文测量或其他 measurement source。
  3. Token 内部 shaping 已由完整 token probe 保留;误差主要发生在 token 之间及逐 token width quantization。
  4. Web/Pretext 的 additive token measurement 接口不能未经验证地直接套用于 Figma。
  5. absoluteRenderBounds 已确认保留亚像素 ink geometry,但不直接等于 advance;SVG root geometry 没有提供额外精度。
  6. Fixed guard differential 已在 CJK/Latin/number/whitespace 样本中精确恢复 contextual advance,并通过三个 CJK guard 的 independence 实验。
  7. Pair shaping 在 AV 内明确存在,但已被当前 Latin-word token 吸收;已测试的实际跨 token pair 均可加。
  8. Kerning pairs on/off 对照确认 AV correction 来自 KERN;guarded whole-token measurement 能自动反映 source OpenType 设置。
  9. 当前结果支持保留 additive MeasureToken interface,并由 Figma adapter 提供 guarded render-bounds measurement。
  10. OPPO Sans、PingFang SC、12px/16px、Regular/Bold 共 55 条 guarded regression line 全部得到 layout delta = 0
  11. Non-zero letter spacing 会固定多计一个 line-end spacing;当前 adapter 必须限制为 letterSpacing = 0,直至 spacing 被独立建模。
  12. 固定 CJK guard 在 Inter 的纯 Latin line 中仍有 -2px unresolved delta,不能作为 Latin-primary font 的最终 measurement contract。
  13. Inter mixed line 的 -5px 已确认完全来自 trailing whitespace,而不是 complex grapheme。
  14. Trailing whitespace 属于 Figma TextNode 的已知 layout 差异,仅记录在本 plugin 文档中;当前不修改 core solver。
  15. Complex grapheme 在 CJK-capable font 下已通过;variable font、mixed styles 与其他复杂 script 仍未验证。