@typeset/figma-plugin-typeset
@typeset/figma-plugin-typeset 是 core engine 与 Figma text pipeline 之间的 adapter,同时保留一个画布内可见的技术验证界面。Core 决定断行和 spacing;本包只负责 Figma measurement、受支持效果的呈现和验证,不重新求解。
运行流程
- 选择一个非空、未旋转、固定宽度或 auto-height、左对齐、uniform-style 且
letterSpacing = 0的TextNode。 - UI iframe 使用 Browser
Intl.Segmenter完成 story、grapheme、token、boundary、spacing、solver 和 Stage 8 layout。Figma main sandbox 不运行这些依赖 Browser API 的步骤。 - Main sandbox 测量 token,把 fractional em advances 通过
postMessage返回 UI。 - UI 将完整
LayoutResult[]返回 main sandbox。 - Rendering adapter 为每个 paragraph 创建独立的普通 Frame。没有基线偏移的 solved line 使用一个 mixed-style TextNode;包含基线偏移的 line 将相邻、非空格且 baseline shift 相同的 tokens 合并为 visual runs,再手动设置 run 的横纵坐标。
Figma 的 Shift+Enter 产生 U+2028 LINE SEPARATOR。它保持在同一个 core paragraph 内,measurement request 会跳过该控制 token,solver 在对应 mandatory boundary 强制换行,renderer 则为换行前后分别创建可见 TextNode。普通 Enter 所形成的 paragraph break 仍由 Story 层拆分。
UI 与 main sandbox 之间显式交换 measurement 和 layout 数据,不提供隐藏所有 pipeline stages 的 convenience orchestration。
Create Typeset Paragraph Frames
Plugin UI 的 Create Typeset paragraph frames 是当前正式输出入口:
- 对选中的 source TextNode 创建一组 guarded measurement probes,并在正式输出成功后删除。
- UI iframe 显式执行 core pipeline,生成
LayoutResult[]。 - 在 source 右侧为每个 paragraph 创建一个同级的 positioned line Frame,依次命名为
paragraph n,默认以零段间距纵向排列;其中的 TextNode 按整个输出连续命名为line n。 - 完成后同时选中所有 paragraph Frames,并在 UI 中显示 paragraph、line、spacing range 和 core diagnostic 数量。
正式输出完成后只锁定内部的每个 line TextNode,paragraph Frame 保持可移动,用户可以直接调整段落位置和间距。Validation demo 保持可编辑,不应用这项锁定与命名策略。
正式操作位于 src/operations/create-typeset-frame.ts,负责连接 measurement adapter 与 rendering adapter,但不替 core 隐藏或重新实现任何 pipeline stage。正式输出在成功、失败或 UI 求解取消时都会清理本次 probes;只有独立的 validation demo 会保留可见 probes 和实验数据。
Composition Settings
Plugin UI 按 Facts、Rules、Style、Policy 和 Debug 分组,使用浏览器原生控件,不包含文本预览。用户点击 Create Typeset paragraph frames 或 Create validation demo 时,当前配置会冻结到本次请求中;后续 measurement / rendering 过程不会读取 UI 的新状态。
Facts:只读展示当前defaultCharacterClassProfile,不在 Figma UI 中编辑 character classes。Rules:启用或禁用 Kinsoku;禁用时显式使用noKinsokuRuleTable,structural mandatory breaks 不受影响。Style:选择start或justify,配置默认及基础字符类的复合字体、相对字号和基线偏移,并启用或禁用 Inline Mojikumi spacing;选择 custom profile 后可编辑 spacing 的 minimum、desired 与 maximum。Policy:独立配置末行最少 effective-unit count、最小 fill ratio 与 line-end hanging mode;末行规则可以同时启用。选择 custom Mojikumi profile 后可编辑 compression 与 expansion priority。Debug:展示选择和请求状态,并提供可见的 validation demo 入口。
Mojikumi 的 profile 选择同时出现在 Style 与 Policy,两处操作同一份配置。Default Inline Mojikumi spacing 与 SimpleChinesePrettySpacing 两个 core profile 只读;custom profile 从默认规则表复制,在插件本次运行期间编辑并直接参与求解。Character classes 固定使用 defaultCharacterClassProfile。Line-end hanging 使用 core 的默认中文 profile,并可选择 none、allow 或 force。
Measurement Adapter
正式入口位于 src/adapter/measurement/measure-token-advances.ts。调用方必须提供:
source:提供 font、size、OpenType features 等 measurement context 的TextNode。tokenRequests:按 text 与 composite-font runs 去重的非空 token measurement request。同一文本使用不同字体或字号时必须是不同 request。guard:用于 guarded differential 的可见字符。probeParent:调用方管理生命周期的可见 probe parent。
Adapter 按候选顺序选择 Figma 中第一个可用的 { family, style } face,并在 token range 上应用字体与 baseFontSize * relativeSize。随后创建 guard + guard baseline 和 guard + token + guard probes,以 absoluteRenderBounds.width 差值返回 advancePx;advanceEm 始终除以 source 的基准字号,而不是相对字号后的 local size。它不创建 solver、renderer 或隐藏 probe。
完整测量实验、原始数据和已知 Figma 差异见 docs/measurement.md。
Rendering Adapter
正式入口位于 src/adapter/rendering/apply-text-layout.ts。applyFigmaTextLayout 接收 source TextNode、原始 sourceText、与 Stage 8 LayoutResult[] 等量且顺序一致的空 paragraphTargets:
- 验证 story、paragraph、line 与 grapheme source ranges 覆盖完整 source;相邻 line ranges 之间只允许跳过一个
U+2028。 - 把每个 paragraph target 配置为固定可用宽度、
layoutMode = "NONE"且不裁剪内容的普通 Frame。 - 每个
LayoutResult只渲染到对应 paragraph target;无基线偏移的LayoutLineclone 一个 auto-width mixed-style TextNode,不依赖 Figma native wrapping 或硬回车。 - 字体与相对字号通过
setRangeFontName、setRangeFontSize写入 line-local UTF-16 ranges。Figma 不接受浏览器式 family fallback list,adapter 按候选顺序选择第一个已安装 face;全部不可用时终止请求。 - 包含非零
baselineShift的行先按现有 tokens 计算位置,再将相邻、非空格且 baseline shift 相同的 tokens 合并成 visual runs。run 的x使用 measured token advances 与 solved spacing 累加。renderer 为整行普通 mixed-style TextNode 及每个普通 visual-run TextNode 创建临时leadingTrim = "CAP_HEIGHT"clone,用 ordinary/trimmedabsoluteRenderBounds的纵向差把 trimmed 底边映射成 ordinary node 内的 baseline offset。最终 run 的y对齐到整行 reference baseline,再减去baselineShift * baseFontSizePx;临时 reference 与 probes 随即删除,正式输出保持leadingTrim = "NONE"。空格只推进坐标,不创建可见节点;行的逻辑高度采用未偏移的整行 reference 高度,不包含 baseline shift。 - 把
paragraph-start/line-startspacing 从 em 转成 px,并直接设置为该行 TextNode 相对 Frame 的x坐标;正值缩进,负值向 inline-start 悬出。 - 同一 composite-font run 内,把
LayoutGrapheme.spacingAfter转成经相对字号校正的PERCENTletter spacing:value = spacingAfter / relativeSize * 100。 - composite-font run 边缘改用
PIXELS:value = spacingAfter * baseFontSizePx,避免 spacing 被边缘任一侧的相对字号缩放。基线偏移行的 token 间位置本身也是 Figma px 坐标。 - 把
line-endspacing 设置为最后一个 grapheme 的 trailing letter spacing,使负值可以缩短逻辑行盒并保留 glyph ink。 - 验证
LayoutLine.lineEndHanging必须指向本行最后一个 grapheme,且 protrusion 不得超过其 token advance;renderer 不把 protrusion 再转换成 spacing。 - 每行
y坐标由前面所有 line nodes 的实际高度累加,最后据此设置 Frame 高度。 - 所有字符 range 都是 line-local UTF-16 range,不截断 surrogate pair 或 ZWJ sequence。
- 不修改 source,不清空非空 target,不重新决定断行。paragraph targets 的父子关系和段间位置仍由调用方决定。
Line-start offset 使用 Figma px 坐标,因此转换为 edgeSpacingEm * baseFontSizePx。同一字体 run 内的 grapheme-pair 与 line-end spacing 使用经过 relativeSize 校正的百分比,复合字体边缘使用 base-em pixel value。空行如果带非零 line-end spacing 会明确报错,因为不存在可承载 trailing spacing 的 grapheme。
Line-end hanging 依靠既有输出结构呈现:solver 用 protrusion 从 occupiedWidth 中扣除末尾标点的部分 advance,但 LayoutLine 仍保留完整文字与 advanceWidth;Figma 的 line TextNode 使用 auto-width,paragraph Frame 保持固定宽度且 clipsContent = false,所以行尾字形会自然越过 Frame。Adapter 不额外施加负 letter spacing,避免把 hanging 与 Mojikumi line-end compression 重复计算。
Renderer 要求 source 未旋转、左对齐且宽度与所有 LayoutResult.availableWidth 一致。每个 line node 都把继承自 source 的 paragraphIndent 与 paragraphSpacing 归零,实际位置完全由 renderer 写入的 x / y 坐标决定。每个 paragraph Frame 的高度只覆盖本段 lines;段间距不固化进 Frame。创建任一 line node 失败时,adapter 会清理本次已经创建的 clones,不留下部分渲染结果。
Validation
运行时 validation 位于 src/validation/create-measurement-validation.ts。点击 Create validation demo 后,画布会保留:
- Guard baseline、isolated/guarded token probes、bounds 和 SVG diagnostics。
- 每条 solved line 的 token measurement 与 Figma auto-width line 对照。
- Figma native wrapping TextNode 与手动定位的 Typeset line Frame 并排结果。
- Magenta native / cyan Typeset overlay。
Validation 使用与正式输出相同的当前 composition settings,包括 alignment、Kinsoku、Inline Mojikumi、两条 last-line policies 与 line-end hanging。Validation 会显示生成的 line node、非零 line-start position、letter-spacing range、line-end spacing 与 hanging line-end 数量。默认 Mojikumi profile 对 line-start 遇到前括号、后括号或句读标点遇到前括号,以及后括号、句读标点或中间标点遇到 line-end 使用压缩 spacing;其他 spacing 仍取决于 solver 是否实际采用相应 adjustment。
连续换行产生的 empty paragraph 已用 第一段\n\n第三段 验证:Figma native pipeline 与 Typeset positioned line Frame 完全重合。当前空行 TextNode 的实际高度可以正确参与手动 y 累加。
段落内的 U+2028 支持普通、连续与末尾 forced line break;连续或末尾 separator 会产生对应的 empty line TextNode。该路径不把 U+2028 发送给 guarded measurement,因此不会再触发空 token advance 错误。
能力边界
- Source measurement context 仍要求 uniform-style text;复合字体样式由插件配置生成,并同时应用于 measurement probes 与输出。
- Figma Plugin API 不提供 range baseline shift 或直接的 baseline metric getter。基线偏移行因此按 visual run 拆分,并使用临时
leadingTrim = "CAP_HEIGHT"clones 测量普通 TextNode 的 baseline offset;最终节点不保留 Vertical trim。该测量依赖absoluteRenderBounds,仍可能受到 Figma render-bounds 精度或特定字体 Vertical trim metrics 的小幅误差影响。 - 一个 token 内若自然出现多个 composite-font runs,字体和字号仍按 range 写入;拆分渲染只采用该 token 第一个 run 的
baselineShift,不额外保证 token 内多种 baseline shift 的视觉正确性。 - visual run 保留 token 内 kerning,并让相邻、同 baseline shift 的非空格 tokens 继续由同一个 Figma TextNode shaping。跨空格或 baseline shift 边界的 run 位置遵循 core 的 additive token measurement 模型。
- 固定 CJK guard 已适用于验证过的 CJK-capable fonts,但不是 Latin-primary font 的最终 measurement contract。
- Grapheme range 使用 JavaScript/Figma 的 UTF-16 indexes;complex grapheme 的范围不会被拆开,但 Figma 是否在 cluster 内应用额外 spacing 仍需通过可见 validation 继续验证。
- Line-end spacing 依赖 Figma 对最后字符 trailing letter spacing 的处理;其逻辑宽度需要继续通过可见样本验证。
- Line-end hanging 依赖 auto-width line TextNode 在不裁剪的固定宽度 Frame 外保持可见;Figma 的实际越界距离仍受既有 measurement 精度差异影响。
- 用户 source 中原有的 mixed styles、non-zero source letter spacing、vertical writing 和完整复杂 inline layout 尚未实现。
Development
从 workspace root 运行:
pnpm --filter @typeset/figma-plugin-typeset build
pnpm --filter @typeset/figma-plugin-typeset typecheck
pnpm --filter @typeset/figma-plugin-typeset lint
pnpm --filter @typeset/figma-plugin-typeset watch:build
Figma 开发插件加载 dist/manifest.json。dist/ 是完整的插件目录,包含生成的 manifest、code.js 与内联所有 UI 资源的 ui.html。插件版本由本包 package.json 的 version 唯一控制,manifest 由 manifest.ts 生成。本包保留独立 TypeScript config,因为构建脚本、main sandbox 与 Browser iframe 使用不同 runtime APIs。