feat: support Figma Make files
This commit is contained in:
parent
d711f7bfeb
commit
6c2e396ed3
637
FIG_DATA_STRUCTURE_ANALYSIS.md
Normal file
637
FIG_DATA_STRUCTURE_ANALYSIS.md
Normal 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。
|
||||||
31
README.md
31
README.md
@ -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 或 PNG,PNG 支持倍率,默认走本地 `figma-like` 渲染。它会在本 MCP 生成的 SVG 上对部分滤镜/内阴影做补偿后再用 `@resvg/resvg-js` 栅格化,目标是接近 Figma 在线端 PNG,但不等同于 Figma 原生 PNG 导出。
|
- `export_fig_node`:把指定节点导出为 SVG 或 PNG,PNG 支持倍率,默认走本地 `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`:
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
344
src/services/fig-make.ts
Normal 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("")
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user