课程 38 - 从设计到代码
变量与主题
Pencil 支持完整的 Design Token 系统,支持多主题条件取值,详见:Variables and Themes。变量系统可以有效减少硬编码,AI 不需要生成具体的颜色值(减少 #RRGGBB 格式错误),也不需要理解设计系统的 token 映射。它只需引用语义化变量名,渲染引擎负责解析。
api.setAppState({
variables: {
'color.background': { type: 'color', value: '#FFFFFF' },
'text.title': { type: 'number', value: 72 },
},
});
api.updateNodes([
{
id: 'r1',
type: 'rect',
x: 0,
y: 0,
width: 100,
height: 100,
zIndex: 1,
fill: '$color.background',
stroke: '$color.background',
},
]);AI 为 dark mode 生成设计时,不需要输出两套颜色方案,只需引用 $color.bg,由节点的 theme 属性决定实际取值。
"variables": {
"color.bg": {
"type": "color",
"value": [
{ "value": "#FFFFFF", "theme": { "mode": "light" } },
{ "value": "#000000", "theme": { "mode": "dark" } }
]
}
}另外 AI 也不需要计算像素值或处理响应式断点。它用声明式语义描述意图,布局引擎自动计算几何。详见 课程 33 - 布局引擎
解析
Figma 支持以下四种类型的变量,详见:Guide to variables in Figma
- Color
#000000 - Number
- String 例如
fontFamily或者文本内容 - Boolean
属性面板与变量选择器
选中节点后,在 Spectrum 属性面板中可以为填充 / 描边颜色 / 线宽 / 字号绑定 AppState.variables 里的设计变量:通过下拉选择变量名即可写入 $token;已绑定时会显示紫色徽标,并可一键解除绑定(写回当前解析后的字面量并记入历史)。取色器与线宽滑块始终按解析后的值展示,避免 $... 直接当作 CSS 颜色无效。
导出 SVG
我们可以有多种导出策略,默认使用解析后的字面量:
export type DesignVariablesSvgExportMode =
/** 解析 $ → 字面量 */
| 'resolved'
/** 保留 `$token` 字符串(属性可能非标准,适合再加工) */
| 'preserve-token'
/** `:root{--x:...}` + `fill="var(--x)"` 形式 */
| 'css-var';也可以使用 CSS variables 导出策略,会在 :root 中声明这些全局变量,便于在浏览器开发者工具中修改。详见:Using CSS custom properties (variables)
<svg>
<defs>
<style>
:root {
--color-background: #2563eb;
--color-stroke: red;
--text-title: 72px;
}
</style>
</defs>
<rect
fill="var(--color-background)"
stroke="var(--color-stroke)"
stroke-width="2"
width="100"
height="100"
id="node-r1"
/>
</svg>icon
icon 在生成 UI 时非常重要,例如 Lucide 已经在 React 组件生成中大规模使用了。Pencil 也支持这种内置图形:
export interface IconFont extends Entity, Size, CanHaveEffects {
type: 'icon_font';
/** Name of the icon in the icon font */
iconFontName?: StringOrVariable;
/** Icon font to use. Valid fonts are 'lucide', 'feather', 'Material Symbols Outlined', 'Material Symbols Rounded', 'Material Symbols Sharp', 'phosphor' */
iconFontFamily?: StringOrVariable;
/** Variable font weight, only valid for icon fonts with variable weight. Values from 100 to 700. */
weight?: NumberOrVariable;
fill?: Fills;
}OpenPencil 的 iconLookup 是可注入的函数,这意味着:
- 灵活性:可以接入任何图标源(Iconify、Lucide、自定义)
- AI 负担:AI 只需要输出 iconFontName: "SearchIcon",具体路径由运行时解析
private drawIconFont(canvas, node, x, y, w, h, opacity) {
const iconName = iNode.iconFontName ?? iNode.name ?? '';
const iconMatch = this.iconLookup?.(iconName) ?? null;
const iconD = iconMatch?.d ?? FALLBACK_ICON_D; // SVG path data
const iconStyle = iconMatch?.style ?? 'stroke'; // stroke or fill
// 解析 SVG path → Skia Path → 缩放适配 → 绘制
}我们的定义如下:
export interface IconFontAttributes {
/** 图标在字体族中的名称 */
iconFontName?: StringOrVariable;
/**
* 字体族。例如:'lucide'、'feather'、'Material Symbols Outlined'、'phosphor' 等。
*/
iconFontFamily?: StringOrVariable;
}
export interface IconFontSerializedNode
extends BaseSerializeNode<'iconfont'>,
Partial<IconFontAttributes>;动态注册 icon 信息
我们使用 IconifyJSON 提供的 icon 类型,可以在运行时动态引入 Lucide、Material 等图标库,它提供了包含图标 SVG 的 JSON:
import { registerIconifyIconSet } from '@infinite-canvas-tutorial/ecs';
const m = await import('@iconify/json/json/lucide.json');
registerIconifyIconSet('lucide', m);然后我们就可以将图标 JSON 转换成我们的场景图表示,例如下面的 Search 图标会被解析成一个 Group 父节点,拥有一个 Path 和 Circle 子节点,这部分和之前将 SVG 元素转换成我们的图形表示几乎一模一样:
"search": {
"body": "<g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"m21 21l-4.34-4.34\"/><circle cx=\"11\" cy=\"11\" r=\"8\"/></g>"
}当然我们需要将宽高、strokeWidth 映射到转换后的场景图上:
function buildIconFontScalablePrimitives(
iconFontName: string,
iconFontFamily: string,
targetWidth: number,
targetHeight: number,
): ScaledIconPrimitive[] {}在图层列表中展示
Iconify 也提供了开箱即用的 Webcomponents 组件用于展示,详见:Iconify Icon web component。这样我们就可以在图层列表项目的缩略图中展示了:
import 'iconify-icon';
if (this.node.type === 'iconfont') {
const iconName = this.#normalizeIconifyName(
this.node as IconFontSerializedNode,
);
thumbnail = iconName
? html`<iconify-icon icon=${iconName}></iconify-icon>`
: html`<sp-icon-group></sp-icon-group>`;
}导出 SVG
结合布局渲染组件
结合 课程 33 - 布局引擎 我们就可以实现带有 icon 的 Button 了:
const button1 = {
id: 'icon-button',
type: 'rect',
fill: 'grey',
display: 'flex',
width: 200,
height: 100,
padding: 10,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
cornerRadius: 10,
gap: 10,
} as const;
const searchIcon = {
id: 'icon-button-search',
parentId: 'icon-button',
type: 'iconfont',
iconFontName: 'search',
iconFontFamily: 'lucide',
};
const text = {
id: 'icon-button-text',
parentId: 'icon-button',
type: 'text',
content: 'Button',
};我们想像 Shadcn UI 一样支持不同变体的 Button 组件,可以减少大量样板代码:
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="destructive">Destructive</Button>组件化生成
.pen 的 ref + descendants 系统本质上是一种面向 AI 的组件继承机制。这样 AI 就不需要理解"圆角矩形 + 文本 + 内边距"的底层构成。它只需引用设计系统已有的 round-button 组件,并覆盖文本内容。这类似于代码中的继承+覆盖。详见:Components and Instances
{
"id": "round-button",
"type": "g",
"reusable": true,
"cornerRadius": 9999,
"children": [
{
"id": "label",
"type": "text",
"content": "Submit",
"fill": "#000000"
...
}
]
}
{
"id": "save-round-button",
"type": "ref",
"ref": "round-button",
"descendants": { "label": { "content": "Save" } }
}Design ↔ Code
| 产品 | 核心策略 | 工程实现 |
|---|---|---|
| OpenPencil | 规则引擎 + 增量管道 | pen-codegen 包提供确定性转换,codegen_plan/submit/assemble/clean 处理大文件 |
| Pencil.dev | AI 自由生成 + 双向同步 | .pen 文件作为上下文,AI 直接输出代码,支持 Code → Design 反向导入 |
Design ↔ Code 的核心洞察是:design-to-code 不是「像素 → 代码」的 AI 猜测。由于 .ic 格式已经内置了对代码友好的原语——flex 布局、设计变量、reusable/ref 组件、语义命名、图标字体——场景图本身就是一份为代码而设计的 IR。因此核心是一个确定性转译器,AI 仅在命名/结构清理这一层锦上添花。
管线
我们仿照 SVG 导出管线,但额外插入一层框架无关的 Code IR,这样新增目标框架只需再写一个 emitter:
.ic SceneGraph
→ expandRefSerializedNodes // 复用现有的 ref/reusable 处理
→ CodeIR // 元素树 + 结构化样式 + 变量引用 + 组件定义
→ Emitter // react-tailwind | html-css | ...Code IR 节点保留:角色(容器 / 文本 / 图标 / 图片 / 形状)、解析前的 $token 引用、结构化的 flex 样式,以及 reusable/ref 的组件关系。
入口
API.exportCode 与 renderToSVG 对称:不传参时转译整个场景,传入节点则转译选区。
const code = api.exportCode(undefined, {
framework: 'react-tailwind', // 或 'html-css'
variablesMode: 'css-var', // 'resolved' | 'preserve-token' | 'css-var'
componentStructure: 'preserve', // 'preserve' | 'flatten'
});也可以直接调用纯函数:
import { serializedNodesToCode } from '@infinite-canvas-tutorial/ecs';
const code = serializedNodesToCode(nodes, {
framework: 'react-tailwind',
variables,
});概念映射
.ic 概念 | 代码产物 |
|---|---|
rect/g + display: flex | <div> + flex 工具类(flex items-center …) |
flexDirection / justify / align / gap / padding | Tailwind flex 工具类 / CSS |
cornerRadius / fills / strokes / dropShadow | rounded-* / bg-* / border / shadow-* |
text + content | 文本节点;$token 文本 → 变量 |
iconfont(lucide) | lucide-react <Search /> |
iconfont(其他族) | @iconify/react <Icon icon="family:name" /> |
$color.bg 变量 | bg-[var(--color-bg)](css-var)/ 字面量(resolved) |
reusable 根 | 一个 React 组件定义 |
ref + descendants 覆盖 | 组件实例 + props |
name | 组件名 / prop 名 / class 名 |
变量模式
三种变量模式与 SVG 导出语义保持一致:
resolved(默认):把$token解析为字面量(如bg-[#FFFFFF])。css-var:输出var(--token)(如bg-[var(--color-bg)]);HTML/CSS emitter 还会注入:root { … }块。preserve-token:保留$token(写入 inlinestyle,便于再加工)。
组件与实例
在 componentStructure: 'preserve'(默认,也是 Pencil 的卖点)下,每个 reusable 根会成为一个组件,每个 ref 成为一次实例调用。各实例上出现过的可覆盖属性(content、fills、fontSize、cornerRadius)会被提升为 props,默认值取自模板:
interface RoundButtonProps {
label?: string;
}
export function RoundButton({ label = 'Submit' }: RoundButtonProps) {
return (
<div className="flex items-center justify-center w-[120px] h-[40px] rounded-[9999px]">
<span>{label}</span>
</div>
);
}
export function Design() {
return <RoundButton label="Save" />;
}componentStructure: 'flatten' 是回退方案:通过 expandRefSerializedNodes 把每个实例展开为具体 DOM,不产出组件定义。HTML/CSS emitter 始终扁平化,因为 HTML 没有组件概念。
反向:code → design
反向(把 JSX/HTML AST 解析回 .ic 节点)更难、歧义也更多,故作为二期。建议先实现本转译器产出代码的幂等回环(design → code → design 不丢信息),再扩展到任意手写代码。
JSON 格式文件
在 课程 10 - 图片导入导出 中我们介绍了如何将图形导出成各种图片格式。在与 AI 交互的过程中,我们需要始终将场景持久化到文件中。在 Figma 中我们可以导入导出 .fig 文件,详见:Import files to the file browser 与 Save a local copy of files
参考 excalidraw 与 pencil 的自定义文件格式,我们也可以设计自己的 JSON 文件,格式为 .ic:
{
"type": "infinite-canvas",
"version": 1,
"source": "https://infinitecanvas.cc",
"variables": {},
"themes": {},
"elements": [],
"appState": {}
}导入时会:删掉当前相机下所有场景根节点,再合并默认 AppState 与文件中的 appState,写入 variables / themes,并在删除完成后 updateNodes(elements);选区里指向不存在节点的 id 会被过滤掉。
import {
downloadIcDocument,
stringifyIcDocument,
} from '@infinite-canvas-tutorial/ecs';
// 导出
const doc = api.exportIcDocument(window.location.origin);
downloadIcDocument(doc, 'my-scene.ic'); // 浏览器里触发下载
// 或自管字符串 / 文件
const text = stringifyIcDocument(doc);
// …写入 .ic 文件或上传
// 导入(可传入对象或 JSON 字符串)
api.importIcDocument(await (await fetch('/scene.ic')).text());在 UI 中,顶部菜单提供了 Export as... > .ic 与 Import from... > .ic,后者会打开文件选择框读取 .ic 文件并调用 api.importIcDocument。