feat: support Figma Make files

This commit is contained in:
sunlei 2026-06-05 10:55:45 +08:00
parent d711f7bfeb
commit 6c2e396ed3
8 changed files with 1113 additions and 8 deletions

View File

@ -0,0 +1,637 @@
# .fig 数据结构与组装逻辑分析
本文档基于当前项目源码,说明本地 `.fig` 文件如何被解析成可查询的设计上下文,以及如何进一步组装成 SVG/PNG 导出结果。
## 1. 总体链路
```mermaid
flowchart TD
A["本地 .fig / .fig.json"] --> B["loadFigFile"]
B --> C["FigJson"]
C --> D["nodeChanges 扁平节点数组"]
C --> E["blobs 几何路径数据"]
A --> F["images/<hash> 图片资源"]
D --> G["getChildrenByParent 重建父子关系"]
G --> H["设计上下文: serializeNode / tokens / codeHints"]
G --> I["renderNodeToSvg 递归渲染"]
E --> I
F --> I
I --> J["SVG"]
J --> K["resvg 栅格化 PNG"]
```
核心结论:
- `.fig` 解码后的节点不是树形结构,而是 `nodeChanges` 扁平数组。
- 节点树通过 `guid``parentIndex.guid` 在运行时重建。
- 子节点层级顺序通过 `parentIndex.position` 排序。
- 矢量路径不直接保存在节点字段里,而是通过 `commandsBlob` 引用 `blobs[index]`
- 图片资源来自 `.fig` 外层 zip 的 `images/<hash>`,不是来自 `FigJson.blobs`
## 2. 文件读取与解码
入口:`src/services/fig-file.ts`
```ts
export function loadFigFile(filePath: string): FigJson {
const absolutePath = path.resolve(filePath)
if (absolutePath.toLowerCase().endsWith(".json")) {
return JSON.parse(fs.readFileSync(absolutePath, "utf8")) as FigJson
}
return figToJson(fs.readFileSync(absolutePath))
}
```
读取策略:
- `.fig.json`:直接作为已经解码过的 JSON 读取。
- `.fig`:调用 `figToJson` 解析 Figma 本地文件。
`.fig` 解码入口:`src/services/fig2json.ts`
主要步骤:
1. 判断输入是否已经是 `fig-kiwi` 格式。
2. 如果不是 `fig-kiwi`,按 zip 解析并取出 `canvas.fig`
3. 从 `canvas.fig` 里切分二进制 parts。
4. 对 part 做解压:
- zstd magic 开头:使用 `fzstd` 解压。
- PNG magic 开头:保留。
- 其他内容:使用 `UZIP.inflateRaw` 解压。
5. 第一个 part 是 schema第二个 part 是 data。
6. 使用 `kiwi-schema` 解码 schema再 decode data。
7. 将 `blobs` 转成 base64得到项目内部使用的 `FigJson`
输出的 `FigJson` 还会附带 `__figmaToJson` 元信息:
```ts
{
__figmaToJson: {
schema: string,
delimiter: number
}
}
```
## 3. 核心数据结构
类型定义入口:`src/services/fig-types.ts`
### 3.1 FigJson
```ts
export type FigJson = {
type?: string
sessionID?: number
ackID?: number
nodeChanges?: FigNode[]
blobs?: string[]
__figmaToJson?: {
schema: string
delimiter: number
}
}
```
字段含义:
- `nodeChanges`:所有节点的扁平列表。
- `blobs`:几何路径 blob已被转为 base64 字符串。
- `sessionID` / `ackID`:文件级元信息。
- `__figmaToJson`:当前项目解码时保存的 schema 和 delimiter 信息。
### 3.2 FigNode
```ts
export type FigNode = {
guid: Guid
parentIndex?: { guid: Guid; position?: string }
type?: string
name?: string
visible?: boolean
opacity?: number
size?: { x: number; y: number }
transform?: FigmaMatrix
fillPaints?: FigPaint[]
strokePaints?: FigPaint[]
fillGeometry?: FigGeometry[]
strokeGeometry?: FigGeometry[]
effects?: FigEffect[]
textData?: FigTextData
derivedTextData?: FigDerivedTextData
}
```
重要字段:
- `guid`:节点唯一标识。
- `parentIndex.guid`:父节点标识。
- `parentIndex.position`:同级节点排序信息,也是 z-order 的关键。
- `type`:节点类型,例如 `DOCUMENT`、`FRAME`、`TEXT`、`VECTOR`、`ELLIPSE`。
- `size`:节点本地尺寸。
- `transform`:节点相对父级的 2D 变换矩阵。
- `fillPaints` / `strokePaints`:填充和描边 paint。
- `fillGeometry` / `strokeGeometry`:几何数据引用。
- `effects`:阴影、模糊等效果。
- `textData` / `derivedTextData`文本内容、样式覆盖、glyph 路径等。
### 3.3 Guid 与 node-id
```ts
export type Guid = {
sessionID: number
localID: number
}
```
项目内部将它标准化为:
```ts
`${sessionID}:${localID}`
```
相关工具:`src/utils/node-id.ts`
- `keyForGuid(guid)`:把 `{ sessionID, localID }` 转成 `sessionID:localID`
- `normalizeNodeId(value)`:支持 `123:456`、`123-456`、`node-id=123-456` 和完整 Figma 链接。
### 3.4 Geometry 与 Blob
```ts
export type FigGeometry = {
commandsBlob: number
windingRule?: string
styleID?: number
}
```
`commandsBlob``FigJson.blobs` 的索引。例如:
```ts
const blob = figJson.blobs?.[geometry.commandsBlob]
```
blob 内容是 base64 编码的紧凑路径命令。渲染时会被解析为 SVG path。
当前支持的路径命令:
| opcode | SVG 命令 | 含义 |
| --- | --- | --- |
| `1` | `M` | moveTo |
| `2` | `L` | lineTo |
| `3` | `Q` | quadraticCurveTo |
| `4` | `C` | cubicCurveTo |
| `0` | `Z` | closePath |
解析入口:`src/services/fig-node-svg.ts` 的 `parsePathBlob`
### 3.5 Paint
```ts
export type FigPaint = {
type: "SOLID" | "GRADIENT_LINEAR" | string
color?: FigColor
opacity?: number
visible?: boolean
stops?: Array<{ color: FigColor; position: number }>
transform?: FigmaMatrix
image?: { hash?: Uint8Array | number[] | string; name?: string }
imageThumbnail?: { hash?: Uint8Array | number[] | string; name?: string }
}
```
当前 SVG 渲染重点支持:
- `SOLID`
- `GRADIENT_LINEAR`
- `GRADIENT_RADIAL`
- `IMAGE`
`IMAGE` paint 会通过 `image.hash``imageThumbnail.hash` 去匹配 `.fig` zip 外层的 `images/<hash>`
## 4. 节点树组装逻辑
组装入口:`src/services/fig-node-svg.ts`
```ts
export function getChildrenByParent(figJson: FigJson): Map<string, FigNode[]> {
const childrenByParent = new Map<string, FigNode[]>()
for (const node of figJson.nodeChanges ?? []) {
if (!node.parentIndex?.guid) continue
const parentKey = keyForGuid(node.parentIndex.guid)
const children = childrenByParent.get(parentKey) ?? []
children.push(node)
childrenByParent.set(parentKey, children)
}
return childrenByParent
}
```
组装结果是:
```ts
Map<parentNodeId, childNodes[]>
```
节点自身 id
```ts
keyForGuid(node.guid)
```
父节点 id
```ts
keyForGuid(node.parentIndex.guid)
```
子节点排序:
```ts
function getSortedChildren(context: RenderContext, node: FigNode): FigNode[] {
return [...(context.childrenByParent.get(keyForGuid(node.guid)) ?? [])].sort((a, b) =>
comparePosition(a.parentIndex?.position ?? "", b.parentIndex?.position ?? "")
)
}
```
排序使用字符串原始比较:
```ts
if (left === right) return 0
return left < right ? -1 : 1
```
原因是 `parentIndex.position` 是 fractional-index 字符串,使用 locale-aware 排序可能会重排标点,从而改变 Figma 原始 z-order。
## 5. 目标节点查找
入口:`findTargetNode`
查找逻辑:
1. 先尝试从 `nodeId``nodeQuery` 中解析标准 node-id。
2. 如果能解析成 `sessionID:localID`,按 `guid` 查找。
3. 如果不能解析,则按节点名称精确匹配。
```ts
const target = normalizedNodeId
? nodes.find((node) => keyForGuid(node.guid) === normalizedNodeId)
: nodes.find((node) => node.name === nodeName)
```
因此当前项目的节点查询不是模糊搜索。模糊搜索只存在于 `listFigNodes` 的查询列表逻辑里。
## 6. 设计上下文组装
入口:`src/services/design-context.ts`
### 6.1 getDesignContext
流程:
1. `loadFigFile` 读取文件。
2. `getChildrenByParent` 组装父子映射。
3. 根据 `nodeQuery` 找目标节点;没有传则找根节点。
4. `serializeNode` 输出节点摘要和子树。
5. 可选输出 tokens。
6. 可选输出 codeHints。
输出结构:
```ts
{
kind: "figma-local-design-context",
filePath,
query,
node,
tokens,
codeHints
}
```
### 6.2 serializeNode
`serializeNode` 会输出:
- 基础摘要id、name、type、size、visible、opacity。
- 父节点 id。
- 子节点数量。
- paint 和 effect 是否存在。
- transform、stroke、arcData。
- 简化 fills / strokes。
- geometry 数量。
- 指定深度内的 children。
这部分适合给模型或上层工具提供可读的设计上下文。
### 6.3 tokens 推导
`collectDesignTokens` 并不读取 Figma 变量或全局样式注册表,而是从当前子树中推导:
- 颜色
- 线性渐变
- 投影
- 描边宽度
源码里也明确说明:本地 `.fig` 解码目前还没有解析 Figma 的变量和样式 registry。
## 7. SVG 组装逻辑
入口:`renderNodeToSvg`
流程:
1. 找目标节点。
2. 重建 `childrenByParent`
3. 初始化 `RenderContext`
4. 调用 `renderNodeSubtree` 递归生成 SVG body。
5. 根据节点尺寸、实际渲染 bounds 和 effect bounds 计算导出 viewBox。
6. 拼接 `<svg>`、`<defs>`、背景和 body。
### 7.1 RenderContext
```ts
type RenderContext = {
figJson: FigJson
childrenByParent: Map<string, FigNode[]>
defs: string[]
rasterHints: FigmaLikeRasterHint[]
imageAssets: FigImageAssets
bounds: Bounds | null
effectBounds: Bounds | null
idSeed: number
pngFigmaLike: boolean
}
```
关键字段:
- `defs`渐变、clipPath、filter 等 SVG 定义。
- `rasterHints`PNG 阶段需要额外像素补偿的信息。
- `imageAssets`:图片 hash 到 data URL 的映射。
- `bounds`:普通可见内容范围。
- `effectBounds`:阴影、模糊等效果扩展范围。
### 7.2 renderNodeSubtree
递归渲染顺序:
1. 跳过 `visible === false` 的节点。
2. 合并父级矩阵和当前节点 transform。
3. 渲染 fill geometry。
4. 渲染文本 glyph。
5. 收集 Figma-like PNG raster hint。
6. 渲染 stroke。
7. 递归渲染 children。
8. 给当前节点包一层 `<g>`,附加 opacity 和 filter。
特殊处理:
- `BOOLEAN_OPERATION` 如果已经有计算后的几何路径,就不再渲染源 children。
- mask 节点会转成 `clipPath`,后续 sibling 会被 clip。
- effect 作为节点级 filter 包在 `<g>` 上,而不是只包单个 path。
### 7.3 transform 组装
Figma 矩阵:
```ts
{
m00, m01, m02,
m10, m11, m12
}
```
SVG matrix
```ts
[m00, m10, m01, m11, m02, m12]
```
转换入口:
```ts
function toSvgMatrix(matrix?: FigmaMatrix): SvgMatrix
```
递归时通过矩阵乘法累积:
```ts
const matrix = multiply(parentMatrix, localMatrix)
```
这样子节点最终会进入根节点坐标空间。
## 8. 几何路径渲染
几何渲染入口:`renderGeometry`
流程:
1. 读取 `fillGeometry``strokeGeometry`
2. 用 `commandsBlob` 找到 `figJson.blobs[index]`
3. `parsePathBlob` 转成 SVG path `d`
4. 计算 bounds。
5. 根据 paint 类型输出对应 SVG。
普通填充:
```svg
<path d="..." fill="..." />
```
奇偶填充规则:
```svg
fill-rule="evenodd"
```
描边优先使用 SVG stroke 属性,而不是直接填充 decoded `strokeGeometry`。原因是 Figma 的 decoded `strokeGeometry` 更像内部栅格使用的扩展轮廓,直接填充可能让细描边变粗。
## 9. 图片资源组装
图片读取入口:`src/services/fig-images.ts`
流程:
1. 只对 `.fig` 生效,`.json` 返回空 Map。
2. 判断文件是否是 zip。
3. 解析 zip。
4. 遍历 `images/` 下的资源。
5. 通过文件头识别 mime
- PNG
- JPEG
- GIF
- WEBP
6. 存成:
```ts
Map<hash, dataUrl>
```
图片渲染入口:`renderImageFill`
流程:
1. 从 `paint.image.hash``paint.imageThumbnail.hash` 得到 hash。
2. 在 `imageAssets` 中查找 data URL。
3. 用当前矢量路径创建 `clipPath`
4. 输出一个单位尺寸 `<image width="1" height="1">`
5. 用 `paint.transform` 和节点尺寸计算 image matrix。
6. 用 wrapper `<g clip-path="...">` 裁切图片。
这样可以把 Figma 的图片裁切、填充和矢量路径边界转成 SVG 近似表达。
## 10. 文本渲染
文本节点不直接输出 `<text>`,而是使用 glyph path。
入口:`renderTextNode`
流程:
1. 判断 `node.type === "TEXT"`
2. 读取 `derivedTextData.glyphs`
3. 每个 glyph 通过 `commandsBlob` 解析出路径。
4. 根据 glyph 的 position、fontSize、rotation 计算矩阵。
5. 根据 `textData.characterStyleIDs``styleOverrideTable` 找到 glyph 对应 paint。
6. 将相同 paint 的 glyph path 合并成一个 `<path>`
关键转换:
```ts
const scale: SvgMatrix = [fontSize, 0, 0, -fontSize, 0, 0]
```
原因是 glyph blob 使用字体轮廓坐标Y 轴向上SVG 本地空间 Y 轴向下,所以要做垂直翻转。
## 11. 渐变与效果
### 11.1 线性渐变
入口:`paintToSvgFill` 中的 `GRADIENT_LINEAR` 分支。
逻辑:
- Figma 的 paint transform 是从对象空间回到单位渐变空间。
- SVG 需要的是 user space 中的 `x1/y1/x2/y2`
- 所以代码会先对 transform 求逆,再投影到节点 box。
### 11.2 径向渐变
入口:`GRADIENT_RADIAL` 分支。
逻辑:
- 使用 `radialGradient`
- 通过 `gradientTransform` 把单位圆投影到节点空间。
### 11.3 阴影与模糊
入口:`createNodeEffectFilter` 和 `createFilter`
支持的主要 effect
- `DROP_SHADOW`
- `INNER_SHADOW`
- `FOREGROUND_BLUR`
- `LAYER_BLUR`
SVG 导出时会生成 `<filter>`,并放入 `context.defs`
PNG 的 `figma-like` 模式对椭圆内阴影有额外处理:
- SVG 阶段记录 `rasterHints`
- PNG 阶段在 `export-node.ts` 中直接操作像素。
- 目的是补偿 `resvg` 和 Figma 原生 PNG 渲染在内阴影边缘上的差异。
## 12. PNG 导出逻辑
入口:`src/services/export-node.ts`
流程:
1. `renderNodeToSvg` 先生成 SVG。
2. 使用 `new Resvg(rendered.svg).render()` 栅格化。
3. 如果是普通预览,直接 `image.asPng()`
4. 如果是 `figma-like`,先拿 RGBA pixels再应用 raster hints。
5. 使用项目内的 PNG encoder 写回 PNG。
当前导出能力说明:
- SVG本地结构化输出。
- PNG本地 SVG 经过 `@resvg/resvg-js` 栅格化。
- Figma-like PNG额外补偿部分滤镜和内阴影。
- Figma native PNG当前不支持因为没有调用 Figma 官方渲染器。
## 13. 当前实现边界
当前项目已经支持:
- 本地 `.fig``.fig.json` 读取。
- 节点列表、节点上下文、设计上下文。
- 子树序列化。
- 颜色、渐变、阴影、描边宽度 token 推导。
- SVG 导出。
- PNG 预览导出。
- 图片填充。
- 文本 glyph path 渲染。
- mask 到 clipPath 的转换。
- 线性和径向渐变。
- 虚线描边。
- 部分 filter 和 Figma-like PNG 补偿。
主要限制:
- 不解析 Figma 官方变量和样式 registry。
- 不保证 PNG 与 Figma native export 像素级一致。
- `getDesignContext` 里的节点查找是精确名称或精确 node-id。
- `.fig.json` 不包含 zip 外层图片资源时,`IMAGE` 填充无法恢复真实图片。
- 复杂 blend mode、组件实例覆盖、变量绑定、auto layout 语义等还没有完整建模。
## 14. 源码入口索引
| 功能 | 文件 | 关键函数 |
| --- | --- | --- |
| 文件读取 | `src/services/fig-file.ts` | `loadFigFile` |
| `.fig` 解码 | `src/services/fig2json.ts` | `figToJson` |
| 类型定义 | `src/services/fig-types.ts` | `FigJson`、`FigNode`、`FigPaint` |
| 节点 id 处理 | `src/utils/node-id.ts` | `keyForGuid`、`normalizeNodeId` |
| 父子关系组装 | `src/services/fig-node-svg.ts` | `getChildrenByParent` |
| 目标节点查找 | `src/services/fig-node-svg.ts` | `findTargetNode` |
| SVG 渲染 | `src/services/fig-node-svg.ts` | `renderNodeToSvg`、`renderNodeSubtree` |
| 路径解析 | `src/services/fig-node-svg.ts` | `parsePathBlob` |
| 图片资源读取 | `src/services/fig-images.ts` | `loadFigImageAssets` |
| 设计上下文 | `src/services/design-context.ts` | `getDesignContext`、`getCodeContext` |
| token 推导 | `src/services/design-context.ts` | `collectDesignTokens` |
| 导出文件 | `src/services/export-node.ts` | `exportFigNode` |
| MCP 工具注册 | `src/mcp/index.ts` | `createServer` |
## 15. 简化伪代码
```ts
const figJson = loadFigFile(filePath)
const imageAssets = loadFigImageAssets(filePath)
const childrenByParent = getChildrenByParent(figJson)
const target = findTargetNode(figJson, { nodeQuery })
const svg = renderNodeToSvg(figJson, {
nodeQuery,
imageAssets,
scale,
pngFigmaLike
})
if (format === "png") {
const image = new Resvg(svg).render()
writePng(image)
} else {
writeSvg(svg)
}
```
这就是当前项目对 `.fig` 的核心处理方式:先把 Figma 本地文件解成扁平数据,再用 `guid` 关系重建节点树,最后把节点、几何 blob、paint、图片资源和 effect 递归组合成可导出的 SVG/PNG。

View File

@ -1,17 +1,18 @@
# Figma Local Context MCP # Figma Local Context MCP
一个本地 `.fig` 文件 MCP Server。它结合了当前项目里的 `.fig` 解码、节点 SVG/PNG 导出能力,以及 `GLips/Figma-Context-MCP` 的 MCP 工具注册方式。 一个本地 `.fig` / Figma Make `.make` 文件 MCP Server。它结合了当前项目里的 `.fig` 解码、节点 SVG/PNG 导出能力,以及 `GLips/Figma-Context-MCP` 的 MCP 工具注册方式。
这个项目不依赖 Figma REST API也不需要 `FIGMA_API_KEY`。MCP 客户端只需要传入本机 `.fig``.fig.json` 路径即可。 这个项目不依赖 Figma REST API也不需要 `FIGMA_API_KEY`。MCP 客户端只需要传入本机 `.fig`、`.make``.fig.json` 路径即可。
## 工具 ## 工具
- `get_design_context`:官方 Figma MCP 风格的主入口返回节点树、样式、tokens 和代码提示。 - `get_design_context`:官方 Figma MCP 风格的主入口返回节点树、样式、tokens 和代码提示。
- `get_code_context`:返回更适合代码生成的布局、样式和导出资源提示。 - `get_code_context`:返回更适合代码生成的布局、样式和导出资源提示。
- `get_make_context`:读取 Figma Make `.make` 文件,返回包结构、源码文件、代码组件/实例、meta 和 AI 对话摘要。
- `export_assets`:批量导出多个节点为 SVG 或 PNG。 - `export_assets`:批量导出多个节点为 SVG 或 PNG。
- `list_fig_nodes`:按名称、类型或 node-id 搜索节点。 - `list_fig_nodes`:按名称、类型或 node-id 搜索节点。
- `get_design_tokens`:从全文件或指定节点子树中推导颜色、渐变、阴影、描边 token。 - `get_design_tokens`:从全文件或指定节点子树中推导颜色、渐变、阴影、描边 token。
- `inspect_fig_file`:读取本地 `.fig`,返回节点数量、类型统计和节点概览。 - `inspect_fig_file`:读取本地 `.fig` / `.make`,返回节点数量、类型统计和节点概览。
- `get_fig_node`:按节点名、`1234:5678`、`1234-5678`、`node-id=1234-5678` 或完整 Figma 链接查找节点,并返回简化上下文。 - `get_fig_node`:按节点名、`1234:5678`、`1234-5678`、`node-id=1234-5678` 或完整 Figma 链接查找节点,并返回简化上下文。
- `export_fig_node`:把指定节点导出为 SVG 或 PNGPNG 支持倍率,默认走本地 `figma-like` 渲染。它会在本 MCP 生成的 SVG 上对部分滤镜/内阴影做补偿后再用 `@resvg/resvg-js` 栅格化,目标是接近 Figma 在线端 PNG但不等同于 Figma 原生 PNG 导出。 - `export_fig_node`:把指定节点导出为 SVG 或 PNGPNG 支持倍率,默认走本地 `figma-like` 渲染。它会在本 MCP 生成的 SVG 上对部分滤镜/内阴影做补偿后再用 `@resvg/resvg-js` 栅格化,目标是接近 Figma 在线端 PNG但不等同于 Figma 原生 PNG 导出。
@ -39,6 +40,28 @@
} }
``` ```
获取 Figma Make 上下文和源码清单:
```json
{
"filePath": "C:\\Users\\you\\Designs\\prototype.make",
"includeSource": false,
"includeAiChat": true,
"maxMessages": 20
}
```
按文件名读取 Make 里的源码内容:
```json
{
"filePath": "C:\\Users\\you\\Designs\\prototype.make",
"fileQuery": "App.tsx",
"includeSource": true,
"sourceMaxLength": 50000
}
```
搜索节点: 搜索节点:
```json ```json
@ -108,7 +131,7 @@
- `local-figma-like-resvg`:默认 PNG 管线。先生成带 Figma-like 滤镜补偿的本地 SVG再用 `@resvg/resvg-js` 转 PNG。 - `local-figma-like-resvg`:默认 PNG 管线。先生成带 Figma-like 滤镜补偿的本地 SVG再用 `@resvg/resvg-js` 转 PNG。
- `local-svg-resvg`:普通预览 PNG 管线。先生成本地 SVG再用 `@resvg/resvg-js` 转 PNG。Figma 在线端 PNG 使用自身渲染管线,不能假定它是在线 SVG 再转 PNG。 - `local-svg-resvg`:普通预览 PNG 管线。先生成本地 SVG再用 `@resvg/resvg-js` 转 PNG。Figma 在线端 PNG 使用自身渲染管线,不能假定它是在线 SVG 再转 PNG。
当前本地 SVG/PNG 管线支持 `.fig` 外层 zip 中的 `images/<hash>` 图片资源,会把 `IMAGE` 填充按真实图片、paint transform 和矢量路径裁切导出;如果本地文件缺少对应图片资源,会明确报出缺失的 IMAGE hash。线性/径向渐变、虚线描边和部分滤镜也会转换为 SVG 近似表达。 当前本地 SVG/PNG 管线支持 `.fig` / `.make` 外层 zip 中的 `images/<hash>` 图片资源,会把 `IMAGE` 填充按真实图片、paint transform 和矢量路径裁切导出;如果本地文件缺少对应图片资源,会明确报出缺失的 IMAGE hash。Figma Make 的 `CODE_INSTANCE.codeSnapshot` 也会作为预览图片参与导出。线性/径向渐变、虚线描边和部分滤镜也会转换为 SVG 近似表达。
导出结果还会包含 `exportCapabilities` 导出结果还会包含 `exportCapabilities`

View File

@ -10,3 +10,4 @@ export {
getDesignTokens, getDesignTokens,
listFigNodes listFigNodes
} from "./services/design-context.js" } from "./services/design-context.js"
export { getMakeContext } from "./services/fig-make.js"

View File

@ -9,6 +9,7 @@ import {
listFigNodes listFigNodes
} from "../services/design-context.js" } from "../services/design-context.js"
import { getFigNodeContext, inspectFigFile } from "../services/fig-file.js" import { getFigNodeContext, inspectFigFile } from "../services/fig-file.js"
import { getMakeContext } from "../services/fig-make.js"
const serverInfo = { const serverInfo = {
name: "Figma Local Context MCP", name: "Figma Local Context MCP",
@ -75,6 +76,15 @@ const codeContextParams = z.object({
depth: z.number().int().min(0).max(8).default(2).describe("返回代码提示子树的深度。") depth: z.number().int().min(0).max(8).default(2).describe("返回代码提示子树的深度。")
}) })
const makeContextParams = z.object({
filePath: z.string().min(1).describe("本地 Figma Make .make、.fig 或 .fig.json 文件路径。"),
includeSource: z.boolean().default(false).describe("是否在结果中包含 CODE_FILE 源码内容。"),
sourceMaxLength: z.number().int().min(0).max(200000).default(20000).describe("每个源码文件最多返回多少字符。"),
fileQuery: z.string().optional().describe("按源码文件名、路径、语言或 node-id 过滤 CODE_FILE。"),
includeAiChat: z.boolean().default(true).describe("是否返回 ai_chat.json 的线程和消息摘要。"),
maxMessages: z.number().int().min(0).max(200).default(20).describe("每个 AI 线程最多返回多少条消息摘要。")
})
const exportAssetsParams = z.object({ const exportAssetsParams = z.object({
filePath: z.string().min(1).describe("本地 .fig 或 .fig.json 文件路径。"), filePath: z.string().min(1).describe("本地 .fig 或 .fig.json 文件路径。"),
nodeQueries: z nodeQueries: z
@ -103,6 +113,7 @@ type ExportNodeParams = z.infer<typeof exportNodeParams>
type ListNodesParams = z.infer<typeof listNodesParams> type ListNodesParams = z.infer<typeof listNodesParams>
type DesignContextParams = z.infer<typeof designContextParams> type DesignContextParams = z.infer<typeof designContextParams>
type CodeContextParams = z.infer<typeof codeContextParams> type CodeContextParams = z.infer<typeof codeContextParams>
type MakeContextParams = z.infer<typeof makeContextParams>
type ExportAssetsParams = z.infer<typeof exportAssetsParams> type ExportAssetsParams = z.infer<typeof exportAssetsParams>
type DesignTokensParams = z.infer<typeof designTokensParams> type DesignTokensParams = z.infer<typeof designTokensParams>
@ -166,6 +177,26 @@ export function createServer(): McpServer {
) )
) )
server.registerTool(
"get_make_context",
{
title: "Get Figma Make context",
description: "读取 Figma Make .make 文件,返回包结构、源码文件、代码组件/实例、meta 和 AI 对话摘要。",
inputSchema: makeContextParams,
annotations: { readOnlyHint: true }
},
async (params: MakeContextParams) =>
jsonResponse(
getMakeContext(params.filePath, {
includeSource: params.includeSource,
sourceMaxLength: params.sourceMaxLength,
fileQuery: params.fileQuery,
includeAiChat: params.includeAiChat,
maxMessages: params.maxMessages
})
)
)
server.registerTool( server.registerTool(
"export_assets", "export_assets",
{ {

344
src/services/fig-make.ts Normal file
View File

@ -0,0 +1,344 @@
import fs from "node:fs"
import path from "node:path"
import UzipModule from "uzip"
import type { FigJson, FigNode, FigPaint, Guid } from "./fig-types.js"
import { loadFigFile, summarizeNode } from "./fig-file.js"
import { getChildrenByParent } from "./fig-node-svg.js"
import { keyForGuid } from "../utils/node-id.js"
const UZIP = (UzipModule as any).default ?? UzipModule
type ZipEntries = Record<string, Uint8Array>
type RawAiChat = {
threads?: RawAiThread[]
}
type RawAiThread = {
id?: string
threadType?: string
title?: string | null
createdAt?: string
updatedAt?: string
messages?: RawAiMessage[]
}
type RawAiMessage = {
id?: string
index?: number
role?: string
createdAt?: string
updatedAt?: string
parts?: RawAiPart[]
}
type RawAiPart = {
partType?: string
contentJson?: string
blobstoreContentKey?: string
}
export type MakeContextOptions = {
includeSource?: boolean
sourceMaxLength?: number
fileQuery?: string
includeAiChat?: boolean
maxMessages?: number
}
export function getMakeContext(filePath: string, options: MakeContextOptions = {}) {
const absolutePath = path.resolve(filePath)
const figJson = loadFigFile(absolutePath)
const entries = tryLoadZipEntries(absolutePath)
const meta = readJsonEntry(entries, "meta.json")
const aiChat = readJsonEntry<RawAiChat>(entries, "ai_chat.json")
const childrenByParent = getChildrenByParent(figJson)
const nodes = figJson.nodeChanges ?? []
const allCodeFiles = collectCodeFiles(figJson, { includeSource: false })
const codeFiles = collectCodeFiles(figJson, options)
const codeComponents = collectCodeComponents(figJson, allCodeFiles)
const codeInstances = collectCodeInstances(figJson, codeComponents)
const canvasMagic = getCanvasMagic(entries)
const isMakeFile =
path.extname(absolutePath).toLowerCase() === ".make" ||
canvasMagic === "fig-make" ||
Boolean(entries?.["ai_chat.json"]) ||
nodes.some((node) => node.type?.startsWith("CODE_") || node.type === "RESPONSIVE_SET")
return {
kind: "figma-make-context",
filePath: absolutePath,
isMakeFile,
canvasMagic,
entries: summarizeEntries(entries),
meta,
document: {
nodeCount: nodes.length,
blobCount: figJson.blobs?.length ?? 0,
topTypes: getTopTypes(figJson),
root: summarizeRootNode(figJson, childrenByParent)
},
code: {
fileCount: nodes.filter((node) => node.type === "CODE_FILE").length,
componentCount: codeComponents.length,
instanceCount: codeInstances.length,
files: codeFiles,
components: codeComponents,
instances: codeInstances
},
aiChat: options.includeAiChat === false ? undefined : summarizeAiChat(aiChat, options.maxMessages ?? 20)
}
}
function collectCodeFiles(figJson: FigJson, options: MakeContextOptions) {
const query = options.fileQuery?.trim().toLowerCase()
const includeSource = options.includeSource ?? false
const sourceMaxLength = Math.max(0, options.sourceMaxLength ?? 20000)
return (figJson.nodeChanges ?? [])
.filter((node) => node.type === "CODE_FILE")
.map((node) => {
const id = keyForGuid(node.guid)
const filePath = node.codeFilePath ?? node.name
const sourceCode = node.sourceCode ?? ""
return {
id,
name: node.name,
path: filePath,
language: inferLanguage(filePath) ?? inferLanguage(node.name),
sourceLength: sourceCode.length,
importedCodeFileCount: node.importedCodeFiles?.length ?? 0,
...includeSourcePreview(sourceCode, includeSource, sourceMaxLength)
}
})
.filter((file) => {
if (!query) return true
return [file.id, file.name, file.path, file.language].some((value) => value?.toLowerCase().includes(query))
})
}
function collectCodeComponents(figJson: FigJson, codeFiles: Array<{ id: string; name?: string; path?: string }>) {
const codeFileById = new Map(codeFiles.map((file) => [file.id, file]))
return (figJson.nodeChanges ?? [])
.filter((node) => node.type === "CODE_COMPONENT")
.map((node) => {
const exportedFromCodeFileId = keyForReference(node.exportedFromCodeFileId)
const codeFile = codeFileById.get(exportedFromCodeFileId)
return {
id: keyForGuid(node.guid),
name: node.name,
exportedFromCodeFileId,
exportedFromCodeFileName: codeFile?.name,
exportedFromCodeFilePath: codeFile?.path,
codeExportName: node.codeExportName,
propCount: node.componentPropDefs?.length ?? 0
}
})
}
function collectCodeInstances(figJson: FigJson, codeComponents: Array<{ id: string; name?: string }>) {
const componentById = new Map(codeComponents.map((component) => [component.id, component]))
return (figJson.nodeChanges ?? [])
.filter((node) => node.type === "CODE_INSTANCE")
.map((node) => {
const backingCodeComponentId = keyForReference(node.backingCodeComponentId)
const component = componentById.get(backingCodeComponentId)
return {
id: keyForGuid(node.guid),
name: node.name,
codeFilePath: node.codeFilePath,
backingCodeComponentId,
backingCodeComponentName: component?.name,
snapshotState: node.codeSnapshot?.state,
snapshotLayoutSize: node.codeSnapshot?.layoutSize,
snapshotCanvasSize: node.codeSnapshot?.canvasSize,
snapshotDevicePixelRatio: node.codeSnapshot?.devicePixelRatio,
previewImageHashes: getPaintImageHashes(node.codeSnapshot?.paints)
}
})
}
function summarizeAiChat(aiChat: RawAiChat | undefined, maxMessages: number) {
const threads = aiChat?.threads ?? []
return {
threadCount: threads.length,
messageCount: threads.reduce((count, thread) => count + (thread.messages?.length ?? 0), 0),
threads: threads.map((thread) => ({
id: thread.id,
threadType: thread.threadType,
title: thread.title,
createdAt: thread.createdAt,
updatedAt: thread.updatedAt,
messageCount: thread.messages?.length ?? 0,
messages: thread.messages?.slice(0, maxMessages).map(summarizeAiMessage) ?? []
}))
}
}
function summarizeAiMessage(message: RawAiMessage) {
const parts = message.parts ?? []
return {
id: message.id,
index: message.index,
role: message.role,
createdAt: message.createdAt,
updatedAt: message.updatedAt,
partTypes: parts.map((part) => part.partType).filter(Boolean),
preview: getMessagePreview(parts)
}
}
function getMessagePreview(parts: RawAiPart[]): string | undefined {
for (const part of parts) {
const content = parseJson(part.contentJson)
const text = pickString(content, "text") ?? pickString(content, "title") ?? pickString(content, "resultJson")
if (text) return text.length > 300 ? `${text.slice(0, 300)}...` : text
}
return undefined
}
function includeSourcePreview(sourceCode: string, includeSource: boolean, sourceMaxLength: number) {
if (!includeSource) return {}
const truncated = sourceCode.length > sourceMaxLength
return {
sourceCode: truncated ? sourceCode.slice(0, sourceMaxLength) : sourceCode,
sourceTruncated: truncated
}
}
function summarizeRootNode(figJson: FigJson, childrenByParent: Map<string, FigNode[]>) {
const root =
figJson.nodeChanges?.find((node) => node.type === "DOCUMENT") ??
figJson.nodeChanges?.find((node) => !node.parentIndex?.guid) ??
figJson.nodeChanges?.[0]
return root ? summarizeNode(root, childrenByParent) : undefined
}
function summarizeEntries(entries: ZipEntries | null) {
if (!entries) return undefined
const names = Object.keys(entries)
return {
count: names.length,
hasCanvas: Boolean(entries["canvas.fig"]),
hasMeta: Boolean(entries["meta.json"]),
hasAiChat: Boolean(entries["ai_chat.json"]),
hasThumbnail: Boolean(entries["thumbnail.png"]),
imageCount: names.filter((name) => name.startsWith("images/") && !name.endsWith("/")).length,
blobStoreCount: names.filter((name) => name.startsWith("blob_store/") && !name.endsWith("/")).length,
names: names.map((name) => ({ name, size: entries[name]?.length ?? 0 }))
}
}
function getTopTypes(figJson: FigJson): Record<string, number> {
const topTypes: Record<string, number> = {}
for (const node of figJson.nodeChanges ?? []) {
const type = node.type ?? "UNKNOWN"
topTypes[type] = (topTypes[type] ?? 0) + 1
}
return Object.fromEntries(Object.entries(topTypes).sort((a, b) => b[1] - a[1]))
}
function getPaintImageHashes(paints: FigPaint[] | undefined): string[] {
if (!Array.isArray(paints)) return []
const hashes = new Set<string>()
for (const paint of paints) {
const imageHash = hashToHex(paint.image?.hash)
const thumbnailHash = hashToHex(paint.imageThumbnail?.hash)
if (imageHash) hashes.add(imageHash)
if (thumbnailHash) hashes.add(thumbnailHash)
}
return [...hashes]
}
function keyForReference(reference: { guid: Guid } | undefined): string {
return keyForGuid(reference?.guid)
}
function inferLanguage(filePath: string | undefined): string | undefined {
if (!filePath) return undefined
const extension = path.extname(filePath).toLowerCase()
const languages: Record<string, string> = {
".css": "css",
".html": "html",
".js": "javascript",
".jsx": "javascriptreact",
".json": "json",
".md": "markdown",
".ts": "typescript",
".tsx": "typescriptreact"
}
return languages[extension] ?? (extension.replace(/^\./, "") || undefined)
}
function readJsonEntry<T = unknown>(entries: ZipEntries | null, entryName: string): T | undefined {
const bytes = entries?.[entryName]
if (!bytes) return undefined
return parseJson(Buffer.from(bytes).toString("utf8")) as T | undefined
}
function parseJson(value: string | undefined): unknown {
if (!value) return undefined
try {
return JSON.parse(value)
} catch {
return undefined
}
}
function pickString(value: unknown, key: string): string | undefined {
if (!value || typeof value !== "object" || !(key in value)) return undefined
const next = (value as Record<string, unknown>)[key]
return typeof next === "string" ? next : undefined
}
function getCanvasMagic(entries: ZipEntries | null): string | undefined {
const canvas = entries?.["canvas.fig"]
if (!canvas || canvas.length < 8) return undefined
return Buffer.from(canvas.subarray(0, 8)).toString("utf8")
}
function tryLoadZipEntries(filePath: string): ZipEntries | null {
const bytes = fs.readFileSync(filePath)
if (!isZipFile(bytes)) return null
return UZIP.parse(toArrayBuffer(bytes)) as ZipEntries
}
function isZipFile(bytes: Uint8Array): boolean {
return bytes[0] === 0x50 && bytes[1] === 0x4b
}
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
}
function hashToHex(hash: Uint8Array | number[] | string | Record<string, number> | undefined): string | null {
if (!hash) return null
if (typeof hash === "string") return hash.toLowerCase()
const values =
hash instanceof Uint8Array
? [...hash]
: Array.isArray(hash)
? hash
: Object.keys(hash)
.sort((left, right) => Number(left) - Number(right))
.map((key) => hash[key])
return values.map((value) => value.toString(16).padStart(2, "0")).join("")
}

View File

@ -169,6 +169,7 @@ function renderNodeSubtree(
node.type !== "BOOLEAN_OPERATION" || !(node.fillGeometry?.length || node.strokeGeometry?.length) node.type !== "BOOLEAN_OPERATION" || !(node.fillGeometry?.length || node.strokeGeometry?.length)
const nodeContent = [ const nodeContent = [
...renderGeometry(context, node, node.fillGeometry, node.fillPaints, matrix), ...renderGeometry(context, node, node.fillGeometry, node.fillPaints, matrix),
renderCodeSnapshot(context, node, matrix),
renderTextNode(context, node, matrix), renderTextNode(context, node, matrix),
collectFigmaLikeEllipseInnerShadowHint(context, node, matrix), collectFigmaLikeEllipseInnerShadowHint(context, node, matrix),
...renderStrokeGeometry(context, node, matrix), ...renderStrokeGeometry(context, node, matrix),
@ -610,6 +611,48 @@ function renderGeometry(
}) })
} }
function renderCodeSnapshot(context: RenderContext, node: FigNode, matrix: SvgMatrix): string {
const snapshot = node.type === "CODE_INSTANCE" ? node.codeSnapshot : undefined
const paints = snapshot?.paints?.filter((paint) => paint.visible !== false)
const size = snapshot?.layoutSize ?? snapshot?.canvasSize ?? node.size
if (!paints?.length || !size || size.x <= 0 || size.y <= 0) return ""
const offset = snapshot?.offset ?? { x: 0, y: 0 }
const bounds = {
minX: offset.x,
minY: offset.y,
maxX: offset.x + size.x,
maxY: offset.y + size.y
}
const parsed = {
d: createRectPath(bounds),
bounds
}
includeBounds(context, transformBounds(matrix, bounds))
// Figma Make stores rendered code components as CODE_INSTANCE.codeSnapshot
// image paints instead of normal fillGeometry. Treat the snapshot as a local
// rectangular paint source so Make prototypes export with their preview.
return paints
.map((paint) => {
if (paint.type === "IMAGE") {
return renderImageFill(context, node, parsed, paint, matrix, "")
}
const fill = paintToSvgFill(context, node, parsed.bounds, paint, matrix)
const opacity = paintOpacityAttribute("fill", paint)
return `<path d="${transformPathData(parsed.d, matrix)}" fill="${fill}"${opacity}/>`
})
.join("")
}
function createRectPath(bounds: Bounds): string {
return `M ${format(bounds.minX)} ${format(bounds.minY)} L ${format(bounds.maxX)} ${format(
bounds.minY
)} L ${format(bounds.maxX)} ${format(bounds.maxY)} L ${format(bounds.minX)} ${format(bounds.maxY)} Z`
}
function renderImageFill( function renderImageFill(
context: RenderContext, context: RenderContext,
node: FigNode, node: FigNode,

View File

@ -39,6 +39,19 @@ export type FigPaint = {
originalImageHeight?: number originalImageHeight?: number
} }
export type FigNodeReference = {
guid: Guid
}
export type FigCodeSnapshot = {
state?: string
paints?: FigPaint[]
offset?: { x: number; y: number }
layoutSize?: { x: number; y: number }
canvasSize?: { x: number; y: number }
devicePixelRatio?: number
}
export type FigTextGlyph = { export type FigTextGlyph = {
commandsBlob: number commandsBlob: number
position?: { x: number; y: number } position?: { x: number; y: number }
@ -116,6 +129,16 @@ export type FigNode = {
fontSize?: number fontSize?: number
textData?: FigTextData textData?: FigTextData
derivedTextData?: FigDerivedTextData derivedTextData?: FigDerivedTextData
sourceCode?: string
codeFilePath?: string
importedCodeFiles?: unknown[]
belongsToCodeLibraryId?: FigNodeReference
exportedFromCodeFileId?: FigNodeReference
codeExportName?: string
backingCodeComponentId?: FigNodeReference
codeSnapshot?: FigCodeSnapshot
responsiveSetSettings?: unknown
componentPropDefs?: unknown[]
} }
export type FigJson = { export type FigJson = {

View File

@ -35,11 +35,11 @@ export function figToJson(fileBuffer: Uint8Array | ArrayBuffer): FigJson {
function figToBinaryParts(fileBuffer: Uint8Array | ArrayBuffer): FigmaBinaryParts { function figToBinaryParts(fileBuffer: Uint8Array | ArrayBuffer): FigmaBinaryParts {
let fileByte: Uint8Array<ArrayBufferLike> = fileBuffer instanceof Uint8Array ? fileBuffer : new Uint8Array(fileBuffer) let fileByte: Uint8Array<ArrayBufferLike> = fileBuffer instanceof Uint8Array ? fileBuffer : new Uint8Array(fileBuffer)
if (!isKiwiFile(fileByte)) { if (!isCanvasFile(fileByte)) {
const unzipped = UZIP.parse(toArrayBuffer(fileByte)) const unzipped = UZIP.parse(toArrayBuffer(fileByte))
const canvas = unzipped["canvas.fig"] const canvas = unzipped["canvas.fig"]
if (!canvas) { if (!canvas) {
throw new Error("未找到 canvas.fig文件可能不是有效的 .fig") throw new Error("未找到 canvas.fig文件可能不是有效的 .fig 或 .make")
} }
fileByte = new Uint8Array(canvas.buffer, canvas.byteOffset, canvas.byteLength) fileByte = new Uint8Array(canvas.buffer, canvas.byteOffset, canvas.byteLength)
} }
@ -83,8 +83,11 @@ function assertFigmaParts(parts: Uint8Array[]): void {
} }
} }
function isKiwiFile(bytes: Uint8Array): boolean { function isCanvasFile(bytes: Uint8Array): boolean {
return bytes.length >= 8 && String.fromCharCode(...bytes.slice(0, 8)) === "fig-kiwi" if (bytes.length < 8) return false
const magic = String.fromCharCode(...bytes.slice(0, 8))
return magic === "fig-kiwi" || magic === "fig-make"
} }
function startsWith(bytes: Uint8Array, magic: number[]): boolean { function startsWith(bytes: Uint8Array, magic: number[]): boolean {