Initial commit

This commit is contained in:
sunlei 2026-05-20 19:14:51 +08:00
commit 7c510c737c
20 changed files with 3978 additions and 0 deletions

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
* text=auto eol=lf

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules
dist
*.log
.env
.DS_Store

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 sunlei
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

155
README.md Normal file
View File

@ -0,0 +1,155 @@
# Figma Local Context MCP
一个本地 `.fig` 文件 MCP Server。它结合了当前项目里的 `.fig` 解码、节点 SVG/PNG 导出能力,以及 `GLips/Figma-Context-MCP` 的 MCP 工具注册方式。
这个项目不依赖 Figma REST API也不需要 `FIGMA_API_KEY`。MCP 客户端只需要传入本机 `.fig``.fig.json` 路径即可。
## 工具
- `get_design_context`:官方 Figma MCP 风格的主入口返回节点树、样式、tokens 和代码提示。
- `get_code_context`:返回更适合代码生成的布局、样式和导出资源提示。
- `export_assets`:批量导出多个节点为 SVG 或 PNG。
- `list_fig_nodes`:按名称、类型或 node-id 搜索节点。
- `get_design_tokens`:从全文件或指定节点子树中推导颜色、渐变、阴影、描边 token。
- `inspect_fig_file`:读取本地 `.fig`,返回节点数量、类型统计和节点概览。
- `get_fig_node`:按节点名、`2625:12945`、`2625-12945`、`node-id=2625-12945` 或完整 Figma 链接查找节点,并返回简化上下文。
- `export_fig_node`:把指定节点导出为 SVG 或 PNGPNG 支持倍率。
## 示例参数
获取官方风格设计上下文:
```json
{
"filePath": "D:\\MyFiles\\Download\\兴泰安全生产预计平台-朱梓鑫.fig",
"nodeQuery": "node-id=2625-12945",
"depth": 2,
"includeTokens": true,
"includeCodeHints": true
}
```
获取代码生成上下文:
```json
{
"filePath": "D:\\MyFiles\\Download\\兴泰安全生产预计平台-朱梓鑫.fig",
"nodeQuery": "2625-12945",
"depth": 2
}
```
搜索节点:
```json
{
"filePath": "D:\\MyFiles\\Download\\兴泰安全生产预计平台-朱梓鑫.fig",
"query": "Group 1321315187",
"limit": 20
}
```
批量导出资源:
```json
{
"filePath": "D:\\MyFiles\\Download\\兴泰安全生产预计平台-朱梓鑫.fig",
"nodeQueries": ["2625-12945", "Group 1321315187"],
"outputDir": "D:\\MyFiles\\Download\\fig-export-debug\\assets",
"format": "png",
"scale": 2
}
```
获取设计 token
```json
{
"filePath": "D:\\MyFiles\\Download\\兴泰安全生产预计平台-朱梓鑫.fig",
"nodeQuery": "2625-12945"
}
```
底层文件概览:
```json
{
"filePath": "D:\\MyFiles\\Download\\兴泰安全生产预计平台-朱梓鑫.fig",
"maxNodes": 20
}
```
底层单节点查询:
```json
{
"filePath": "D:\\MyFiles\\Download\\兴泰安全生产预计平台-朱梓鑫.fig",
"nodeQuery": "node-id=2625-12945",
"depth": 1
}
```
底层单节点导出:
```json
{
"filePath": "D:\\MyFiles\\Download\\兴泰安全生产预计平台-朱梓鑫.fig",
"nodeQuery": "2625-12945",
"outputPath": "D:\\MyFiles\\Download\\fig-export-debug\\mcp-export-node.png",
"format": "png",
"scale": 2
}
```
## 开发
```bash
pnpm install
pnpm typecheck
pnpm build
```
本地调试:
```bash
pnpm dev
```
MCP 客户端配置示例:
```json
{
"mcpServers": {
"Figma Local Context": {
"command": "cmd",
"args": ["/c", "pnpm", "--dir", "D:\\MyFiles\\Codes\\Node\\figma-local-context-mcp", "dev"]
}
}
}
```
构建后也可以使用:
```json
{
"mcpServers": {
"Figma Local Context": {
"command": "cmd",
"args": ["/c", "node", "D:\\MyFiles\\Codes\\Node\\figma-local-context-mcp\\dist\\bin.js", "--stdio"]
}
}
}
```
## 引用与致谢
本项目在实现过程中参考并复用了以下开源项目的思路或能力:
- [GLips/Figma-Context-MCP](https://github.com/GLips/Figma-Context-MCP):参考它的 MCP Server 组织方式、工具注册结构和官方 Figma MCP 风格的上下文输出思路。该仓库根目录提供 MIT License`package.json` 也声明为 MIT。
- [yagudaev/figma-to-json](https://github.com/yagudaev/figma-to-json):参考它对 `.fig` 文件读写和解码的实现方向并在本项目中继续扩展本地节点检索、SVG/PNG 导出和 MCP 上下文能力。该仓库根 `package.json` 声明 `license: MIT`;当前主分支没有单独的根 LICENSE 文件。
## 许可证兼容性
上面两个引用仓库均声明为 MIT 许可证。MIT 许可证允许使用、复制、修改、合并、发布、分发、再许可和销售软件副本,因此支持本项目的私有使用、商业使用、二次开发和发布。需要遵守的核心条件是:分发包含这些项目的代码或其重要部分时,应保留对应的版权声明和许可声明。
本项目同样使用 MIT License见 [LICENSE](./LICENSE)。

59
package.json Normal file
View File

@ -0,0 +1,59 @@
{
"name": "figma-local-context-mcp",
"version": "0.1.0",
"description": "Local MCP server for reading .fig files, inspecting nodes, and exporting SVG/PNG assets without the Figma API.",
"type": "module",
"main": "dist/index.js",
"bin": {
"figma-local-context-mcp": "dist/bin.js"
},
"files": [
"dist",
"README.md",
"server.json"
],
"scripts": {
"build": "tsup --dts",
"typecheck": "tsc --noEmit",
"start": "node dist/bin.js --stdio",
"dev": "tsx src/bin.ts --stdio",
"inspect": "pnpx @modelcontextprotocol/inspector"
},
"engines": {
"node": ">=20.20.0"
},
"packageManager": "pnpm@10.33.0",
"keywords": [
"figma",
"mcp",
"fig",
"typescript"
],
"repository": {
"type": "git",
"url": "git+https://github.com/KwiTsukasa/figma-local-context-mcp.git"
},
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "1.29.0",
"@resvg/resvg-js": "^2.6.2",
"cleye": "^2.2.1",
"fzstd": "^0.1.1",
"kiwi-schema": "^0.5.0",
"uzip": "^0.20201231.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^25.3.3",
"@types/uzip": "^0.20201231.0",
"tsup": "^8.5.1",
"tsx": "^4.22.3",
"typescript": "^5.7.3"
},
"pnpm": {
"onlyBuiltDependencies": [
"@resvg/resvg-js",
"esbuild"
]
}
}

2143
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

19
server.json Normal file
View File

@ -0,0 +1,19 @@
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json",
"name": "figma-local-context-mcp",
"description": "Read local .fig files, inspect node data, and export selected nodes to SVG/PNG.",
"status": "active",
"repository": {
"url": "file://.",
"source": "local"
},
"packages": [
{
"registry_type": "npm",
"identifier": "figma-local-context-mcp",
"transport": {
"type": "stdio"
}
}
]
}

28
src/bin.ts Normal file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env node
import { cli } from "cleye"
import { startStdioServer } from "./server.js"
const argv = cli({
name: "figma-local-context-mcp",
version: process.env.NPM_PACKAGE_VERSION ?? "0.1.0",
flags: {
stdio: {
type: Boolean,
description: "Run in stdio transport mode for MCP clients"
}
}
})
async function main(): Promise<void> {
if (!argv.flags.stdio) {
process.stderr.write("当前版本只支持 stdio请传入 --stdio。\n")
}
await startStdioServer()
}
main().catch((error) => {
const message = error instanceof Error ? error.message : String(error)
process.stderr.write(`启动失败:${message}\n`)
process.exit(1)
})

12
src/index.ts Normal file
View File

@ -0,0 +1,12 @@
export { createServer } from "./mcp/index.js"
export { startStdioServer } from "./server.js"
export { figToJson } from "./services/fig2json.js"
export { inspectFigFile, getFigNodeContext, loadFigFile } from "./services/fig-file.js"
export { exportFigNode } from "./services/export-node.js"
export {
exportAssets,
getCodeContext,
getDesignContext,
getDesignTokens,
listFigNodes
} from "./services/design-context.js"

247
src/mcp/index.ts Normal file
View File

@ -0,0 +1,247 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import { z } from "zod"
import { exportFigNode } from "../services/export-node.js"
import {
exportAssets,
getCodeContext,
getDesignContext,
getDesignTokens,
listFigNodes
} from "../services/design-context.js"
import { getFigNodeContext, inspectFigFile } from "../services/fig-file.js"
const serverInfo = {
name: "Figma Local Context MCP",
version: process.env.NPM_PACKAGE_VERSION ?? "0.1.0",
description: "Read local .fig files, inspect node context, and export selected nodes without Figma API."
}
const inspectParams = z.object({
filePath: z.string().min(1).describe("本地 .fig 或 .fig.json 文件路径。"),
maxNodes: z.number().int().positive().max(2000).default(200).describe("最多返回多少个节点摘要。")
})
const getNodeParams = z.object({
filePath: z.string().min(1).describe("本地 .fig 或 .fig.json 文件路径。"),
nodeQuery: z
.string()
.min(1)
.describe("节点名称、2625:12945、2625-12945、node-id=2625-12945 或完整 Figma 链接。"),
depth: z.number().int().min(0).max(10).default(2).describe("返回节点子树的深度。")
})
const exportNodeParams = z.object({
filePath: z.string().min(1).describe("本地 .fig 或 .fig.json 文件路径。"),
nodeQuery: z
.string()
.min(1)
.describe("节点名称、2625:12945、2625-12945、node-id=2625-12945 或完整 Figma 链接。"),
outputPath: z.string().min(1).optional().describe("输出文件路径;不传时写到当前工作目录。"),
format: z.enum(["svg", "png"]).default("png").describe("导出格式。"),
scale: z.number().positive().max(10).default(2).describe("导出倍率PNG 常用 1/2/3/4。"),
background: z.string().optional().describe("可选背景色,例如 #ffffff。")
})
const listNodesParams = z.object({
filePath: z.string().min(1).describe("本地 .fig 或 .fig.json 文件路径。"),
query: z.string().optional().describe("按节点名、类型或 node-id 片段搜索。"),
type: z.string().optional().describe("按 Figma 节点类型过滤,例如 FRAME、TEXT、VECTOR。"),
limit: z.number().int().positive().max(5000).default(200).describe("最多返回多少个节点。"),
includeHidden: z.boolean().default(false).describe("是否包含 visible=false 的节点。")
})
const designContextParams = z.object({
filePath: z.string().min(1).describe("本地 .fig 或 .fig.json 文件路径。"),
nodeQuery: z
.string()
.optional()
.describe("节点名称、2625:12945、2625-12945、node-id=2625-12945 或完整 Figma 链接;不传则返回文档根节点上下文。"),
depth: z.number().int().min(0).max(8).default(2).describe("返回节点子树的深度。"),
includeTokens: z.boolean().default(true).describe("是否附带从当前子树推导出的设计 token。"),
includeCodeHints: z.boolean().default(true).describe("是否附带代码实现提示。")
})
const codeContextParams = z.object({
filePath: z.string().min(1).describe("本地 .fig 或 .fig.json 文件路径。"),
nodeQuery: z
.string()
.min(1)
.describe("节点名称、2625:12945、2625-12945、node-id=2625-12945 或完整 Figma 链接。"),
depth: z.number().int().min(0).max(8).default(2).describe("返回代码提示子树的深度。")
})
const exportAssetsParams = z.object({
filePath: z.string().min(1).describe("本地 .fig 或 .fig.json 文件路径。"),
nodeQueries: z
.array(z.string().min(1))
.min(1)
.max(100)
.describe("要导出的节点名或 node-id 列表。"),
outputDir: z.string().min(1).describe("资源输出目录。"),
format: z.enum(["svg", "png"]).default("png").describe("导出格式。"),
scale: z.number().positive().max(10).default(2).describe("导出倍率PNG 常用 1/2/3/4。"),
background: z.string().optional().describe("可选背景色,例如 #ffffff。")
})
const designTokensParams = z.object({
filePath: z.string().min(1).describe("本地 .fig 或 .fig.json 文件路径。"),
nodeQuery: z.string().optional().describe("可选节点范围;不传则从文档根节点提取。")
})
type InspectParams = z.infer<typeof inspectParams>
type GetNodeParams = z.infer<typeof getNodeParams>
type ExportNodeParams = z.infer<typeof exportNodeParams>
type ListNodesParams = z.infer<typeof listNodesParams>
type DesignContextParams = z.infer<typeof designContextParams>
type CodeContextParams = z.infer<typeof codeContextParams>
type ExportAssetsParams = z.infer<typeof exportAssetsParams>
type DesignTokensParams = z.infer<typeof designTokensParams>
export function createServer(): McpServer {
const server = new McpServer(serverInfo)
server.registerTool(
"list_fig_nodes",
{
title: "List local Figma nodes",
description: "按名称、类型或 node-id 搜索本地 .fig/.fig.json 里的节点。",
inputSchema: listNodesParams,
annotations: { readOnlyHint: true }
},
async (params: ListNodesParams) =>
jsonResponse(
listFigNodes(params.filePath, {
query: params.query,
type: params.type,
limit: params.limit,
includeHidden: params.includeHidden
})
)
)
server.registerTool(
"get_design_context",
{
title: "Get design context",
description: "官方 Figma MCP 风格的设计上下文工具返回节点树、样式、tokens 和代码提示。",
inputSchema: designContextParams,
annotations: { readOnlyHint: true }
},
async (params: DesignContextParams) =>
jsonResponse(
getDesignContext({
filePath: params.filePath,
nodeQuery: params.nodeQuery,
depth: params.depth,
includeTokens: params.includeTokens,
includeCodeHints: params.includeCodeHints
})
)
)
server.registerTool(
"get_code_context",
{
title: "Get code context",
description: "返回适合代码生成的布局、样式、导出资源提示和子节点实现线索。",
inputSchema: codeContextParams,
annotations: { readOnlyHint: true }
},
async (params: CodeContextParams) =>
jsonResponse(
getCodeContext({
filePath: params.filePath,
nodeQuery: params.nodeQuery,
depth: params.depth
})
)
)
server.registerTool(
"export_assets",
{
title: "Export design assets",
description: "批量导出本地 .fig/.fig.json 中的多个节点为 SVG 或 PNG。",
inputSchema: exportAssetsParams,
annotations: { readOnlyHint: false, openWorldHint: true }
},
async (params: ExportAssetsParams) =>
jsonResponse(
exportAssets({
filePath: params.filePath,
nodeQueries: params.nodeQueries,
outputDir: params.outputDir,
format: params.format,
scale: params.scale,
background: params.background
})
)
)
server.registerTool(
"get_design_tokens",
{
title: "Get design tokens",
description: "从本地 .fig/.fig.json 的全文件或指定节点子树中推导颜色、渐变、阴影和描边 token。",
inputSchema: designTokensParams,
annotations: { readOnlyHint: true }
},
async (params: DesignTokensParams) => jsonResponse(getDesignTokens(params.filePath, params.nodeQuery))
)
server.registerTool(
"inspect_fig_file",
{
title: "Inspect local .fig file",
description: "读取本地 .fig/.fig.json返回节点数量、类型统计和节点摘要。",
inputSchema: inspectParams,
annotations: { readOnlyHint: true }
},
async (params: InspectParams) => jsonResponse(inspectFigFile(params.filePath, params.maxNodes))
)
server.registerTool(
"get_fig_node",
{
title: "Get local Figma node",
description: "按节点名或 node-id 从本地 .fig/.fig.json 中查找节点,并返回简化上下文。",
inputSchema: getNodeParams,
annotations: { readOnlyHint: true }
},
async (params: GetNodeParams) => jsonResponse(getFigNodeContext(params.filePath, params.nodeQuery, params.depth))
)
server.registerTool(
"export_fig_node",
{
title: "Export local Figma node",
description: "把本地 .fig/.fig.json 中的指定节点导出为 SVG 或 PNG。",
inputSchema: exportNodeParams,
annotations: { readOnlyHint: false, openWorldHint: true }
},
async (params: ExportNodeParams) =>
jsonResponse(
exportFigNode({
filePath: params.filePath,
nodeQuery: params.nodeQuery,
outputPath: params.outputPath,
format: params.format,
scale: params.scale,
background: params.background
})
)
)
return server
}
function jsonResponse(value: unknown) {
return {
content: [
{
type: "text" as const,
text: JSON.stringify(value, null, 2)
}
]
}
}

17
src/server.ts Normal file
View File

@ -0,0 +1,17 @@
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { createServer } from "./mcp/index.js"
export async function startStdioServer(): Promise<void> {
const server = createServer()
const transport = new StdioServerTransport()
await server.connect(transport)
process.on("SIGINT", async () => {
await server.close()
process.exit(0)
})
process.on("SIGTERM", async () => {
await server.close()
process.exit(0)
})
}

View File

@ -0,0 +1,276 @@
import path from "node:path"
import type { FigColor, FigJson, FigNode, FigPaint } from "./fig-types.js"
import { exportFigNode, type ExportNodeResult } from "./export-node.js"
import { getChildrenByParent, findTargetNode } from "./fig-node-svg.js"
import { getRootNode, loadFigFile, serializeNode, summarizeNode } from "./fig-file.js"
import { keyForGuid, sanitizeFilePart } from "../utils/node-id.js"
export type NodeListOptions = {
query?: string
type?: string
limit: number
includeHidden: boolean
}
export type DesignContextOptions = {
filePath: string
nodeQuery?: string
depth: number
includeTokens: boolean
includeCodeHints: boolean
}
export type CodeContextOptions = {
filePath: string
nodeQuery: string
depth: number
}
export type ExportAssetsOptions = {
filePath: string
nodeQueries: string[]
outputDir: string
format: "svg" | "png"
scale: number
background?: string
}
export function listFigNodes(filePath: string, options: NodeListOptions) {
const figJson = loadFigFile(filePath)
const childrenByParent = getChildrenByParent(figJson)
const normalizedQuery = options.query?.trim().toLowerCase()
const normalizedType = options.type?.trim().toUpperCase()
const nodes = (figJson.nodeChanges ?? [])
.filter((node) => options.includeHidden || node.visible !== false)
.filter((node) => !normalizedType || node.type?.toUpperCase() === normalizedType)
.filter((node) => {
if (!normalizedQuery) return true
const haystack = `${node.name ?? ""} ${node.type ?? ""} ${keyForGuid(node.guid)}`.toLowerCase()
return haystack.includes(normalizedQuery)
})
.slice(0, options.limit)
return {
filePath: path.resolve(filePath),
query: options.query,
type: options.type,
count: nodes.length,
nodes: nodes.map((node) => summarizeNode(node, childrenByParent))
}
}
export function getDesignContext(options: DesignContextOptions) {
const figJson = loadFigFile(options.filePath)
const childrenByParent = getChildrenByParent(figJson)
const target = options.nodeQuery ? findTargetNode(figJson, { nodeQuery: options.nodeQuery }) : getRootNode(figJson)
if (!target) {
throw new Error("文件中没有可用节点")
}
return {
kind: "figma-local-design-context",
filePath: path.resolve(options.filePath),
query: options.nodeQuery,
node: serializeNode(target, childrenByParent, options.depth),
tokens: options.includeTokens ? collectDesignTokens(figJson, target, childrenByParent) : undefined,
codeHints: options.includeCodeHints ? buildCodeHints(target, childrenByParent, options.depth) : undefined
}
}
export function getCodeContext(options: CodeContextOptions) {
const figJson = loadFigFile(options.filePath)
const childrenByParent = getChildrenByParent(figJson)
const target = findTargetNode(figJson, { nodeQuery: options.nodeQuery })
return {
kind: "figma-local-code-context",
filePath: path.resolve(options.filePath),
query: options.nodeQuery,
node: summarizeNode(target, childrenByParent),
implementationHints: buildCodeHints(target, childrenByParent, options.depth)
}
}
export function exportAssets(options: ExportAssetsOptions) {
const results: ExportNodeResult[] = []
for (const nodeQuery of options.nodeQueries) {
const fileName = `${sanitizeFilePart(nodeQuery)}${options.format === "png" ? `@${options.scale}x` : ""}.${options.format}`
results.push(
exportFigNode({
filePath: options.filePath,
nodeQuery,
outputPath: path.join(options.outputDir, fileName),
format: options.format,
scale: options.scale,
background: options.background
})
)
}
return {
filePath: path.resolve(options.filePath),
outputDir: path.resolve(options.outputDir),
count: results.length,
assets: results
}
}
export function getDesignTokens(filePath: string, nodeQuery?: string) {
const figJson = loadFigFile(filePath)
const childrenByParent = getChildrenByParent(figJson)
const target = nodeQuery ? findTargetNode(figJson, { nodeQuery }) : getRootNode(figJson)
if (!target) {
throw new Error("文件中没有可用节点")
}
return {
filePath: path.resolve(filePath),
query: nodeQuery,
tokens: collectDesignTokens(figJson, target, childrenByParent)
}
}
function buildCodeHints(node: FigNode, childrenByParent: Map<string, FigNode[]>, depth: number): unknown {
const children = childrenByParent.get(keyForGuid(node.guid)) ?? []
const hints = {
id: keyForGuid(node.guid),
name: node.name,
type: node.type,
box: {
width: node.size?.x,
height: node.size?.y,
x: node.transform?.m02,
y: node.transform?.m12
},
css: {
opacity: node.opacity,
fills: simplifyPaintsForCode(node.fillPaints),
strokes: simplifyPaintsForCode(node.strokePaints),
strokeWidth: node.strokeWeight,
shadows: node.effects
?.filter((effect) => effect.visible !== false && effect.type === "DROP_SHADOW")
.map((effect) => ({
x: effect.offset?.x ?? 0,
y: effect.offset?.y ?? 0,
blur: effect.radius ?? 0,
spread: effect.spread ?? 0,
color: colorToCss(effect.color)
}))
},
exportHint:
node.type === "VECTOR" || node.type === "ELLIPSE" || node.type === "FRAME"
? `Use export_fig_node or export_assets with nodeQuery "${keyForGuid(node.guid)}" for exact SVG/PNG.`
: undefined
}
if (depth <= 0) return hints
return {
...hints,
children: children
.sort((a, b) => (a.parentIndex?.position ?? "").localeCompare(b.parentIndex?.position ?? ""))
.map((child) => buildCodeHints(child, childrenByParent, depth - 1))
}
}
function collectDesignTokens(figJson: FigJson, root: FigNode, childrenByParent: Map<string, FigNode[]>) {
const colors = new Map<string, { value: string; count: number; examples: string[] }>()
const gradients = new Map<string, { value: string[]; count: number; examples: string[] }>()
const shadows = new Map<string, { value: unknown; count: number; examples: string[] }>()
const strokeWidths = new Map<string, { value: number; count: number; examples: string[] }>()
// Tokens are inferred from the selected subtree instead of global styles,
// because local .fig decoding does not yet resolve Figma's variable/style registry.
for (const node of walkSubtree(root, childrenByParent)) {
const example = `${node.name ?? "未命名"} (${keyForGuid(node.guid)})`
for (const paint of [...(node.fillPaints ?? []), ...(node.strokePaints ?? [])]) {
if (paint.visible === false) continue
if (paint.type === "SOLID") {
addToken(colors, colorToCss(paint.color), colorToCss(paint.color), example)
} else if (paint.type === "GRADIENT_LINEAR") {
const stops = paint.stops?.map((stop) => `${formatPercent(stop.position)} ${colorToCss(stop.color)}`) ?? []
addToken(gradients, stops.join(" | "), stops, example)
}
}
for (const effect of node.effects ?? []) {
if (effect.visible === false || effect.type !== "DROP_SHADOW") continue
const value = {
x: effect.offset?.x ?? 0,
y: effect.offset?.y ?? 0,
blur: effect.radius ?? 0,
spread: effect.spread ?? 0,
color: colorToCss(effect.color)
}
addToken(shadows, JSON.stringify(value), value, example)
}
if (node.strokeWeight != null) {
addToken(strokeWidths, String(node.strokeWeight), node.strokeWeight, example)
}
}
return {
nodeCount: figJson.nodeChanges?.length ?? 0,
colors: [...colors.values()],
gradients: [...gradients.values()],
shadows: [...shadows.values()],
strokeWidths: [...strokeWidths.values()]
}
}
function* walkSubtree(root: FigNode, childrenByParent: Map<string, FigNode[]>): Generator<FigNode> {
yield root
for (const child of childrenByParent.get(keyForGuid(root.guid)) ?? []) {
yield* walkSubtree(child, childrenByParent)
}
}
function addToken<T>(
map: Map<string, { value: T; count: number; examples: string[] }>,
key: string,
value: T,
example: string
) {
const current = map.get(key)
if (current) {
current.count += 1
if (current.examples.length < 5) current.examples.push(example)
return
}
map.set(key, { value, count: 1, examples: [example] })
}
function simplifyPaintsForCode(paints: FigPaint[] | undefined) {
if (!paints?.length) return undefined
return paints
.filter((paint) => paint.visible !== false)
.map((paint) => {
if (paint.type === "SOLID") return colorToCss(paint.color)
if (paint.type === "GRADIENT_LINEAR") {
const stops = paint.stops?.map((stop) => `${colorToCss(stop.color)} ${formatPercent(stop.position)}`) ?? []
return `linear-gradient(${stops.join(", ")})`
}
return paint.type
})
}
function colorToCss(color?: FigColor): string {
if (!color) return "rgb(0 0 0 / 1)"
return `rgb(${toByte(color.r)} ${toByte(color.g)} ${toByte(color.b)} / ${trimNumber(color.a)})`
}
function toByte(value: number): number {
return Math.round(Math.max(0, Math.min(1, value)) * 255)
}
function formatPercent(value: number): string {
return `${trimNumber(value * 100)}%`
}
function trimNumber(value: number): string {
return Number.isInteger(value) ? String(value) : Number(value.toFixed(4)).toString()
}

View File

@ -0,0 +1,67 @@
import fs from "node:fs"
import path from "node:path"
import { Resvg } from "@resvg/resvg-js"
import { loadFigFile } from "./fig-file.js"
import { renderNodeToSvg } from "./fig-node-svg.js"
import { keyForGuid, sanitizeFilePart } from "../utils/node-id.js"
export type ExportNodeOptions = {
filePath: string
nodeQuery: string
outputPath?: string
format: "svg" | "png"
scale: number
background?: string
}
export type ExportNodeResult = {
filePath: string
outputPath: string
format: "svg" | "png"
scale: number
width: number
height: number
node: {
id: string
name?: string
type?: string
}
}
export function exportFigNode(options: ExportNodeOptions): ExportNodeResult {
const figJson = loadFigFile(options.filePath)
const rendered = renderNodeToSvg(figJson, {
nodeQuery: options.nodeQuery,
scale: options.scale,
background: options.background
})
const outputPath = path.resolve(options.outputPath ?? defaultOutputPath(options))
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
if (options.format === "svg") {
fs.writeFileSync(outputPath, rendered.svg)
} else {
fs.writeFileSync(outputPath, new Resvg(rendered.svg).render().asPng())
}
return {
filePath: path.resolve(options.filePath),
outputPath,
format: options.format,
scale: options.scale,
width: rendered.width,
height: rendered.height,
node: {
id: keyForGuid(rendered.node.guid),
name: rendered.node.name,
type: rendered.node.type
}
}
}
function defaultOutputPath(options: ExportNodeOptions): string {
const input = path.parse(options.filePath)
const nodePart = sanitizeFilePart(options.nodeQuery)
const scalePart = options.format === "png" ? `@${options.scale}x` : ""
return path.join(process.cwd(), `${input.name}-${nodePart}${scalePart}.${options.format}`)
}

138
src/services/fig-file.ts Normal file
View File

@ -0,0 +1,138 @@
import fs from "node:fs"
import path from "node:path"
import type { FigJson, FigNode } from "./fig-types.js"
import { figToJson } from "./fig2json.js"
import { findTargetNode, getChildrenByParent } from "./fig-node-svg.js"
import { keyForGuid, normalizeNodeId } from "../utils/node-id.js"
export type NodeSummary = {
id: string
name?: string
type?: string
parentId?: string
visible?: boolean
opacity?: number
size?: { x: number; y: number }
childCount: number
hasFill: boolean
hasStroke: boolean
hasEffects: boolean
}
export type FileSummary = {
filePath: string
nodeCount: number
blobCount: number
topTypes: Record<string, number>
nodes: NodeSummary[]
}
export function loadFigFile(filePath: string): FigJson {
const absolutePath = path.resolve(filePath)
if (!fs.existsSync(absolutePath)) {
throw new Error(`文件不存在:${absolutePath}`)
}
if (absolutePath.toLowerCase().endsWith(".json")) {
return JSON.parse(fs.readFileSync(absolutePath, "utf8")) as FigJson
}
return figToJson(fs.readFileSync(absolutePath))
}
export function inspectFigFile(filePath: string, maxNodes: number): FileSummary {
const figJson = loadFigFile(filePath)
const childrenByParent = getChildrenByParent(figJson)
const nodes = figJson.nodeChanges ?? []
const topTypes: Record<string, number> = {}
for (const node of nodes) {
const type = node.type ?? "UNKNOWN"
topTypes[type] = (topTypes[type] ?? 0) + 1
}
return {
filePath: path.resolve(filePath),
nodeCount: nodes.length,
blobCount: figJson.blobs?.length ?? 0,
topTypes: Object.fromEntries(Object.entries(topTypes).sort((a, b) => b[1] - a[1])),
nodes: nodes.slice(0, maxNodes).map((node) => summarizeNode(node, childrenByParent))
}
}
export function getFigNodeContext(filePath: string, nodeQuery: string, depth: number): unknown {
const figJson = loadFigFile(filePath)
const target = findTargetNode(figJson, { nodeQuery })
const childrenByParent = getChildrenByParent(figJson)
return {
filePath: path.resolve(filePath),
query: nodeQuery,
normalizedNodeId: normalizeNodeId(nodeQuery),
node: serializeNode(target, childrenByParent, depth)
}
}
export function getRootNode(figJson: FigJson): FigNode | undefined {
const nodes = figJson.nodeChanges ?? []
return (
nodes.find((node) => node.type === "DOCUMENT") ??
nodes.find((node) => !node.parentIndex?.guid) ??
nodes[0]
)
}
export function summarizeNode(node: FigNode, childrenByParent: Map<string, FigNode[]>): NodeSummary {
return {
id: keyForGuid(node.guid),
name: node.name,
type: node.type,
parentId: keyForGuid(node.parentIndex?.guid) || undefined,
visible: node.visible,
opacity: node.opacity,
size: node.size,
childCount: childrenByParent.get(keyForGuid(node.guid))?.length ?? 0,
hasFill: Boolean(node.fillPaints?.length),
hasStroke: Boolean(node.strokePaints?.length),
hasEffects: Boolean(node.effects?.length)
}
}
export function serializeNode(node: FigNode, childrenByParent: Map<string, FigNode[]>, depth: number): unknown {
const children = childrenByParent.get(keyForGuid(node.guid)) ?? []
const summary = {
...summarizeNode(node, childrenByParent),
transform: node.transform,
strokeWeight: node.strokeWeight,
strokeAlign: node.strokeAlign,
arcData: node.arcData,
fills: simplifyPaints(node.fillPaints),
strokes: simplifyPaints(node.strokePaints),
effects: node.effects,
geometry: {
fillCount: node.fillGeometry?.length ?? 0,
strokeCount: node.strokeGeometry?.length ?? 0
}
}
if (depth <= 0) return summary
return {
...summary,
children: children
.sort((a, b) => (a.parentIndex?.position ?? "").localeCompare(b.parentIndex?.position ?? ""))
.map((child) => serializeNode(child, childrenByParent, depth - 1))
}
}
function simplifyPaints(paints: FigNode["fillPaints"]): unknown[] | undefined {
if (!paints?.length) return undefined
return paints.map((paint) => ({
type: paint.type,
visible: paint.visible,
opacity: paint.opacity,
color: paint.color,
stops: paint.stops
}))
}

View File

@ -0,0 +1,541 @@
import type {
Bounds,
FigArcData,
FigColor,
FigEffect,
FigGeometry,
FigJson,
FigNode,
FigPaint,
FigmaMatrix
} from "./fig-types.js"
import { keyForGuid, normalizeNodeId } from "../utils/node-id.js"
type SvgMatrix = [number, number, number, number, number, number]
type ParsedPath = {
d: string
bounds: Bounds
}
type RenderContext = {
figJson: FigJson
childrenByParent: Map<string, FigNode[]>
defs: string[]
bounds: Bounds | null
idSeed: number
}
export type RenderOptions = {
nodeName?: string
nodeId?: string
nodeQuery?: string
scale?: number
background?: string
}
export type RenderedSvg = {
svg: string
width: number
height: number
viewBox: Bounds
node: FigNode
}
const IDENTITY: SvgMatrix = [1, 0, 0, 1, 0, 0]
const COMMAND_MOVE = 1
const COMMAND_LINE = 2
const COMMAND_CUBIC = 4
const COMMAND_CLOSE = 0
export function renderNodeToSvg(figJson: FigJson, options: RenderOptions): RenderedSvg {
const target = findTargetNode(figJson, options)
const childrenByParent = getChildrenByParent(figJson)
const context: RenderContext = {
figJson,
childrenByParent,
defs: [],
bounds: null,
idSeed: 0
}
const body = renderNodeSubtree(context, target, IDENTITY, true)
const bounds = context.bounds ?? { minX: 0, minY: 0, maxX: target.size?.x ?? 0, maxY: target.size?.y ?? 0 }
const width = bounds.maxX - bounds.minX
const height = bounds.maxY - bounds.minY
const scale = options.scale ?? 2
const background = options.background
? `<rect x="${format(bounds.minX)}" y="${format(bounds.minY)}" width="${format(width)}" height="${format(height)}" fill="${escapeAttribute(options.background)}"/>`
: ""
const svg = [
`<svg xmlns="http://www.w3.org/2000/svg" width="${format(width * scale)}" height="${format(
height * scale
)}" viewBox="${format(bounds.minX)} ${format(bounds.minY)} ${format(width)} ${format(height)}" shape-rendering="geometricPrecision">`,
context.defs.length ? `<defs>${context.defs.join("")}</defs>` : "",
background,
body,
"</svg>"
].join("")
return { svg, width: width * scale, height: height * scale, viewBox: bounds, node: target }
}
export function findTargetNode(figJson: FigJson, options: RenderOptions): FigNode {
const nodes = figJson.nodeChanges ?? []
const rawQuery = options.nodeId ?? options.nodeQuery
const normalizedNodeId = normalizeNodeId(rawQuery)
const nodeName = options.nodeName ?? (normalizedNodeId ? undefined : options.nodeQuery)
const target = normalizedNodeId
? nodes.find((node) => keyForGuid(node.guid) === normalizedNodeId)
: nodes.find((node) => node.name === nodeName)
if (!target) {
throw new Error(`找不到节点:${options.nodeId ?? options.nodeName ?? options.nodeQuery}`)
}
return target
}
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
}
function renderNodeSubtree(
context: RenderContext,
node: FigNode,
parentMatrix: SvgMatrix,
isRoot = false
): string {
if (node.visible === false) return ""
const localMatrix = isRoot ? IDENTITY : toSvgMatrix(node.transform)
const matrix = multiply(parentMatrix, localMatrix)
const nodeContent = [
...renderGeometry(context, node, node.fillGeometry, node.fillPaints, matrix),
...renderStrokeGeometry(context, node, matrix),
...getSortedChildren(context, node).map((child) => renderNodeSubtree(context, child, matrix))
].join("")
if (!nodeContent) return ""
const transform = matrixToAttribute(localMatrix)
const opacity = node.opacity != null && node.opacity !== 1 ? ` opacity="${format(node.opacity)}"` : ""
const filterId = createNodeEffectFilter(context, node, matrix)
const filter = filterId ? ` filter="url(#${filterId})"` : ""
return `<g${transform}${opacity}${filter}>${nodeContent}</g>`
}
function renderStrokeGeometry(context: RenderContext, node: FigNode, matrix: SvgMatrix): string[] {
const outsideEllipseStroke = renderOutsideEllipseStroke(context, node, matrix)
if (outsideEllipseStroke) return outsideEllipseStroke
return renderGeometry(context, node, node.strokeGeometry, node.strokePaints, matrix)
}
function renderOutsideEllipseStroke(context: RenderContext, node: FigNode, matrix: SvgMatrix): string[] | null {
const strokeWeight = node.strokeWeight ?? 0
if (
node.type !== "ELLIPSE" ||
node.strokeAlign !== "OUTSIDE" ||
!node.size ||
!node.strokePaints?.length ||
strokeWeight <= 0 ||
!isFullEllipse(node.arcData)
) {
return null
}
const bounds = {
minX: -strokeWeight,
minY: -strokeWeight,
maxX: node.size.x + strokeWeight,
maxY: node.size.y + strokeWeight
}
includeBounds(context, transformBounds(matrix, bounds))
return node.strokePaints
.filter((paint) => paint.visible !== false)
.map((paint) => {
const stroke = paintToSvgFill(context, node, bounds, paint)
const opacity = paint.opacity != null && paint.opacity !== 1 ? ` stroke-opacity="${format(paint.opacity)}"` : ""
const rx = node.size!.x / 2 + strokeWeight / 2
const ry = node.size!.y / 2 + strokeWeight / 2
// Figma's strokeGeometry for OUTSIDE ellipses is an expanded filled
// outline. Using it directly paints inward and erases the ring gap, so
// complete ellipses are emitted as actual outside strokes.
return `<ellipse cx="${format(node.size!.x / 2)}" cy="${format(node.size!.y / 2)}" rx="${format(
rx
)}" ry="${format(ry)}" fill="none" stroke="${stroke}" stroke-width="${format(strokeWeight)}"${opacity}/>`
})
}
function isFullEllipse(arcData?: FigArcData): boolean {
if (!arcData) return true
const start = arcData.startingAngle ?? 0
const end = arcData.endingAngle ?? Math.PI * 2
const delta = Math.abs(end - start)
return (arcData.innerRadius ?? 0) === 0 && Math.abs(delta - Math.PI * 2) < 0.001
}
function renderGeometry(
context: RenderContext,
node: FigNode,
geometries: FigGeometry[] | undefined,
paints: FigPaint[] | undefined,
matrix: SvgMatrix
): string[] {
if (!geometries?.length || !paints?.length) return []
return geometries.flatMap((geometry) => {
const parsed = parsePathBlob(context.figJson, geometry.commandsBlob)
const transformedBounds = transformBounds(matrix, parsed.bounds)
includeBounds(context, transformedBounds)
return paints
.filter((paint) => paint.visible !== false)
.map((paint) => {
const fill = paintToSvgFill(context, node, parsed.bounds, paint)
const opacity = paint.opacity != null && paint.opacity !== 1 ? ` fill-opacity="${format(paint.opacity)}"` : ""
return `<path d="${parsed.d}" fill="${fill}"${opacity}/>`
})
})
}
function parsePathBlob(figJson: FigJson, blobIndex: number): ParsedPath {
const blob = figJson.blobs?.[blobIndex]
if (!blob) {
throw new Error(`缺少几何数据 blob${blobIndex}`)
}
const bytes = base64ToBytes(blob)
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
let offset = 0
let d = ""
const bounds = createEmptyBounds()
const readFloat = () => {
const value = view.getFloat32(offset, true)
offset += 4
return value
}
const readPoint = () => {
const x = readFloat()
const y = readFloat()
includePoint(bounds, x, y)
return `${format(x)} ${format(y)}`
}
// Figma stores flattened vector commands as compact opcodes followed by
// little-endian float coordinates: 1=M, 2=L, 4=C, 0=Z.
while (offset < bytes.length) {
const command = bytes[offset]
offset += 1
if (command === COMMAND_MOVE) {
d += `M ${readPoint()} `
} else if (command === COMMAND_LINE) {
d += `L ${readPoint()} `
} else if (command === COMMAND_CUBIC) {
d += `C ${readPoint()} ${readPoint()} ${readPoint()} `
} else if (command === COMMAND_CLOSE) {
d += "Z "
} else {
throw new Error(`几何数据 blob ${blobIndex} 中存在不支持的向量命令:${command}`)
}
}
return { d: d.trim(), bounds }
}
function paintToSvgFill(context: RenderContext, node: FigNode, pathBounds: Bounds, paint: FigPaint): string {
if (paint.type === "SOLID") {
return colorToCss(paint.color)
}
if (paint.type === "GRADIENT_LINEAR") {
const id = nextId(context, "gradient")
const gradient = getLinearGradientLine(node, pathBounds, paint)
const stops = paint.stops
?.map(
(stop) =>
`<stop offset="${format(stop.position * 100)}%" stop-color="${colorToCss(stop.color)}" stop-opacity="${format(
stop.color.a
)}"/>`
)
.join("")
context.defs.push(
`<linearGradient id="${id}" gradientUnits="userSpaceOnUse" x1="${format(gradient.x1)}" y1="${format(
gradient.y1
)}" x2="${format(gradient.x2)}" y2="${format(gradient.y2)}">${stops ?? ""}</linearGradient>`
)
return `url(#${id})`
}
throw new Error(`不支持的填充类型:${paint.type}`)
}
function getLinearGradientLine(node: FigNode, pathBounds: Bounds, paint: FigPaint) {
const width = node.size?.x || pathBounds.maxX - pathBounds.minX
const height = node.size?.y || pathBounds.maxY - pathBounds.minY
const originX = pathBounds.minX < 0 ? pathBounds.minX : 0
const originY = pathBounds.minY < 0 ? pathBounds.minY : 0
const inverse = invert(toSvgMatrix(paint.transform))
// Figma's stored gradient transform maps local object space back into the
// unit gradient space, so SVG needs the inverse projected onto the node box.
const start = applyToPoint(inverse, 0, 0)
const end = applyToPoint(inverse, 1, 0)
return {
x1: originX + start.x * width,
y1: originY + start.y * height,
x2: originX + end.x * width,
y2: originY + end.y * height
}
}
function createFilter(context: RenderContext, effects: FigEffect[] | undefined, bounds: Bounds): string | null {
const shadow = effects?.find((effect) => effect.visible !== false && effect.type === "DROP_SHADOW")
if (!shadow) return null
const id = nextId(context, "shadow")
const radius = shadow.radius ?? 0
const spread = shadow.spread ?? 0
const offsetX = shadow.offset?.x ?? 0
const offsetY = shadow.offset?.y ?? 0
const x = bounds.minX + Math.min(0, offsetX) - radius - spread
const y = bounds.minY + Math.min(0, offsetY) - radius - spread
const width = bounds.maxX - bounds.minX + Math.abs(offsetX) + radius * 2 + spread * 2
const height = bounds.maxY - bounds.minY + Math.abs(offsetY) + radius * 2 + spread * 2
const sourceAlpha = spread
? `<feMorphology in="SourceAlpha" operator="${spread > 0 ? "dilate" : "erode"}" radius="${format(
Math.abs(spread)
)}" result="spreadAlpha"/>`
: ""
const blurInput = spread ? "spreadAlpha" : "SourceAlpha"
const shadowResult = shadow.showShadowBehindNode === false ? "visibleShadow" : "shadow"
const hideShadowBehindSource =
shadow.showShadowBehindNode === false
? `<feComposite in="shadow" in2="SourceAlpha" operator="out" result="visibleShadow"/>`
: ""
// Figma can store showShadowBehindNode=false. SVG's feDropShadow always
// leaves the blurred shadow under the source, so we build the filter steps
// manually and subtract SourceAlpha when Figma says the shadow is outside-only.
context.defs.push(
`<filter id="${id}" x="${format(x)}" y="${format(y)}" width="${format(width)}" height="${format(
height
)}" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">${sourceAlpha}<feGaussianBlur in="${blurInput}" stdDeviation="${format(
radius / 2
)}" result="blurredShadow"/><feOffset in="blurredShadow" dx="${format(offsetX)}" dy="${format(
offsetY
)}" result="offsetShadow"/><feFlood flood-color="${colorToCss(shadow.color)}" flood-opacity="${format(
shadow.color?.a ?? 1
)}" result="shadowColor"/><feComposite in="shadowColor" in2="offsetShadow" operator="in" result="shadow"/>${hideShadowBehindSource}<feMerge><feMergeNode in="${shadowResult}"/><feMergeNode in="SourceGraphic"/></feMerge></filter>`
)
return id
}
function createNodeEffectFilter(context: RenderContext, node: FigNode, matrix: SvgMatrix): string | null {
const localBounds = getNodeLocalBounds(context, node)
if (!localBounds) return null
// Effects belong to the Figma layer itself. Frames/groups can carry shadows
// even when their own fill paints are empty, so the filter must wrap the
// rendered layer content instead of only individual geometry paths.
const filterId = createFilter(context, node.effects, localBounds)
if (!filterId) return null
includeBounds(context, transformBounds(matrix, expandBoundsForEffects(localBounds, node.effects)))
return filterId
}
function getNodeLocalBounds(context: RenderContext, node: FigNode): Bounds | null {
const geometryBounds = getGeometryLocalBounds(context, node)
if (geometryBounds) return geometryBounds
if (!node.size) return null
return {
minX: 0,
minY: 0,
maxX: node.size.x,
maxY: node.size.y
}
}
function getGeometryLocalBounds(context: RenderContext, node: FigNode): Bounds | null {
const geometries = [...(node.fillGeometry ?? []), ...(node.strokeGeometry ?? [])]
let bounds: Bounds | null = null
for (const geometry of geometries) {
const parsed = parsePathBlob(context.figJson, geometry.commandsBlob)
bounds = bounds ? unionBounds(bounds, parsed.bounds) : { ...parsed.bounds }
}
return bounds && Number.isFinite(bounds.minX) ? bounds : null
}
function getSortedChildren(context: RenderContext, node: FigNode): FigNode[] {
return [...(context.childrenByParent.get(keyForGuid(node.guid)) ?? [])].sort((a, b) =>
(a.parentIndex?.position ?? "").localeCompare(b.parentIndex?.position ?? "")
)
}
function nextId(context: RenderContext, prefix: string): string {
context.idSeed += 1
return `${prefix}-${context.idSeed}`
}
function toSvgMatrix(matrix?: FigmaMatrix): SvgMatrix {
if (!matrix) return IDENTITY
return [matrix.m00, matrix.m10, matrix.m01, matrix.m11, matrix.m02, matrix.m12]
}
function matrixToAttribute(matrix: SvgMatrix): string {
if (matrix.every((value, index) => value === IDENTITY[index])) return ""
return ` transform="matrix(${matrix.map(format).join(" ")})"`
}
function multiply(left: SvgMatrix, right: SvgMatrix): SvgMatrix {
const [a1, b1, c1, d1, e1, f1] = left
const [a2, b2, c2, d2, e2, f2] = right
return [
a1 * a2 + c1 * b2,
b1 * a2 + d1 * b2,
a1 * c2 + c1 * d2,
b1 * c2 + d1 * d2,
a1 * e2 + c1 * f2 + e1,
b1 * e2 + d1 * f2 + f1
]
}
function invert(matrix: SvgMatrix): SvgMatrix {
const [a, b, c, d, e, f] = matrix
const determinant = a * d - b * c
if (Math.abs(determinant) < Number.EPSILON) return IDENTITY
return [
d / determinant,
-b / determinant,
-c / determinant,
a / determinant,
(c * f - d * e) / determinant,
(b * e - a * f) / determinant
]
}
function applyToPoint(matrix: SvgMatrix, x: number, y: number) {
const [a, b, c, d, e, f] = matrix
return {
x: a * x + c * y + e,
y: b * x + d * y + f
}
}
function transformBounds(matrix: SvgMatrix, bounds: Bounds): Bounds {
const points = [
applyToPoint(matrix, bounds.minX, bounds.minY),
applyToPoint(matrix, bounds.maxX, bounds.minY),
applyToPoint(matrix, bounds.maxX, bounds.maxY),
applyToPoint(matrix, bounds.minX, bounds.maxY)
]
return points.reduce((next, point) => includePoint(next, point.x, point.y), createEmptyBounds())
}
function expandBoundsForEffects(bounds: Bounds, effects: FigEffect[] | undefined): Bounds {
const next = { ...bounds }
for (const effect of effects ?? []) {
if (effect.visible === false || effect.type !== "DROP_SHADOW") continue
const radius = effect.radius ?? 0
const spread = effect.spread ?? 0
const offsetX = effect.offset?.x ?? 0
const offsetY = effect.offset?.y ?? 0
next.minX = Math.min(next.minX, bounds.minX + offsetX - radius - spread)
next.maxX = Math.max(next.maxX, bounds.maxX + offsetX + radius + spread)
next.minY = Math.min(next.minY, bounds.minY + offsetY - radius - spread)
next.maxY = Math.max(next.maxY, bounds.maxY + offsetY + radius + spread)
}
return next
}
function createEmptyBounds(): Bounds {
return {
minX: Number.POSITIVE_INFINITY,
minY: Number.POSITIVE_INFINITY,
maxX: Number.NEGATIVE_INFINITY,
maxY: Number.NEGATIVE_INFINITY
}
}
function includeBounds(context: RenderContext, bounds: Bounds) {
context.bounds = context.bounds
? {
minX: Math.min(context.bounds.minX, bounds.minX),
minY: Math.min(context.bounds.minY, bounds.minY),
maxX: Math.max(context.bounds.maxX, bounds.maxX),
maxY: Math.max(context.bounds.maxY, bounds.maxY)
}
: { ...bounds }
}
function unionBounds(left: Bounds, right: Bounds): Bounds {
return {
minX: Math.min(left.minX, right.minX),
minY: Math.min(left.minY, right.minY),
maxX: Math.max(left.maxX, right.maxX),
maxY: Math.max(left.maxY, right.maxY)
}
}
function includePoint(bounds: Bounds, x: number, y: number): Bounds {
bounds.minX = Math.min(bounds.minX, x)
bounds.minY = Math.min(bounds.minY, y)
bounds.maxX = Math.max(bounds.maxX, x)
bounds.maxY = Math.max(bounds.maxY, y)
return bounds
}
function colorToCss(color?: FigColor): string {
if (!color) return "#000000"
return `rgb(${toByte(color.r)} ${toByte(color.g)} ${toByte(color.b)})`
}
function toByte(value: number): number {
return Math.round(Math.max(0, Math.min(1, value)) * 255)
}
function base64ToBytes(value: string): Uint8Array {
const buffer = Buffer.from(value, "base64")
return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)
}
function escapeAttribute(value: string): string {
return value.replace(/"/g, "&quot;")
}
function format(value: number): string {
return Number.isInteger(value) ? String(value) : Number(value.toFixed(4)).toString()
}

89
src/services/fig-types.ts Normal file
View File

@ -0,0 +1,89 @@
export type Guid = {
sessionID: number
localID: number
}
export type FigmaMatrix = {
m00: number
m01: number
m02: number
m10: number
m11: number
m12: number
}
export type FigArcData = {
startingAngle?: number
endingAngle?: number
innerRadius?: number
}
export type FigColor = {
r: number
g: number
b: number
a: number
}
export type FigPaint = {
type: "SOLID" | "GRADIENT_LINEAR" | string
color?: FigColor
opacity?: number
visible?: boolean
stops?: Array<{ color: FigColor; position: number }>
transform?: FigmaMatrix
}
export type FigGeometry = {
commandsBlob: number
windingRule?: string
styleID?: number
}
export type FigEffect = {
type: string
visible?: boolean
offset?: { x: number; y: number }
radius?: number
spread?: number
showShadowBehindNode?: boolean
color?: FigColor
}
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
strokeWeight?: number
strokeAlign?: "CENTER" | "INSIDE" | "OUTSIDE"
arcData?: FigArcData
fillPaints?: FigPaint[]
strokePaints?: FigPaint[]
fillGeometry?: FigGeometry[]
strokeGeometry?: FigGeometry[]
effects?: FigEffect[]
}
export type FigJson = {
type?: string
sessionID?: number
ackID?: number
nodeChanges?: FigNode[]
blobs?: string[]
__figmaToJson?: {
schema: string
delimiter: number
}
}
export type Bounds = {
minX: number
minY: number
maxX: number
maxY: number
}

104
src/services/fig2json.ts Normal file
View File

@ -0,0 +1,104 @@
import { ByteBuffer, compileSchema, decodeBinarySchema } from "kiwi-schema"
import { decompress as decompressZstd } from "fzstd"
import UzipModule from "uzip"
import type { FigJson } from "./fig-types.js"
const UZIP = (UzipModule as any).default ?? UzipModule
const FIGMA_TO_JSON_METADATA_KEY = "__figmaToJson"
const ZSTD_MAGIC = [0x28, 0xb5, 0x2f, 0xfd]
const PNG_MAGIC = [137, 80, 78, 71]
type FigmaBinaryParts = {
delimiter: number
parts: Uint8Array<ArrayBufferLike>[]
}
export function figToJson(fileBuffer: Uint8Array | ArrayBuffer): FigJson {
const { delimiter, parts } = figToBinaryParts(fileBuffer)
assertFigmaParts(parts)
const [schemaByte, dataByte] = parts
const schema = decodeBinarySchema(new ByteBuffer(schemaByte))
const dataBB = new ByteBuffer(dataByte)
const schemaHelper = compileSchema(schema)
const json = schemaHelper.decodeMessage(dataBB) as FigJson
return {
...convertBlobsToBase64(json),
[FIGMA_TO_JSON_METADATA_KEY]: {
schema: bytesToBase64(schemaByte),
delimiter
}
}
}
function figToBinaryParts(fileBuffer: Uint8Array | ArrayBuffer): FigmaBinaryParts {
let fileByte: Uint8Array<ArrayBufferLike> = fileBuffer instanceof Uint8Array ? fileBuffer : new Uint8Array(fileBuffer)
if (!isKiwiFile(fileByte)) {
const unzipped = UZIP.parse(toArrayBuffer(fileByte))
const canvas = unzipped["canvas.fig"]
if (!canvas) {
throw new Error("未找到 canvas.fig文件可能不是有效的 .fig")
}
fileByte = new Uint8Array(canvas.buffer, canvas.byteOffset, canvas.byteLength)
}
let start = 8
const delimiter = readUint32(fileByte, start)
start += 4
const parts: Uint8Array<ArrayBufferLike>[] = []
while (start < fileByte.length) {
const size = readUint32(fileByte, start)
start += 4
let part: Uint8Array<ArrayBufferLike> = fileByte.slice(start, start + size)
if (startsWith(part, ZSTD_MAGIC)) {
part = decompressZstd(part)
} else if (!startsWith(part, PNG_MAGIC)) {
part = UZIP.inflateRaw(part)
}
parts.push(part)
start += size
}
return { delimiter, parts }
}
function convertBlobsToBase64(json: FigJson): FigJson {
const blobs = (json as any).blobs
if (!Array.isArray(blobs)) return json
return {
...json,
blobs: blobs.map((blob: any) => bytesToBase64(blob.bytes ?? blob))
}
}
function assertFigmaParts(parts: Uint8Array[]): void {
if (parts.length < 2) {
throw new Error("fig 文件缺少 schema 或 data 数据段")
}
}
function isKiwiFile(bytes: Uint8Array): boolean {
return bytes.length >= 8 && String.fromCharCode(...bytes.slice(0, 8)) === "fig-kiwi"
}
function startsWith(bytes: Uint8Array, magic: number[]): boolean {
return magic.every((value, index) => bytes[index] === value)
}
function readUint32(bytes: Uint8Array, offset: number): number {
return new DataView(bytes.buffer, bytes.byteOffset + offset, 4).getUint32(0, true)
}
function bytesToBase64(bytes: Uint8Array): string {
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64")
}
function toArrayBuffer(bytes: Uint8Array<ArrayBufferLike>): ArrayBuffer {
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
}

30
src/utils/node-id.ts Normal file
View File

@ -0,0 +1,30 @@
import type { Guid } from "../services/fig-types.js"
export function keyForGuid(guid?: Guid): string {
return guid ? `${guid.sessionID}:${guid.localID}` : ""
}
export function normalizeNodeId(value?: string): string | null {
if (!value) return null
const text = safeDecodeURIComponent(value.trim())
const nodeIdParam = text.match(/(?:^|[?&#\s])node-id=([^&#\s]+)/i)?.[1]
const candidate = safeDecodeURIComponent(nodeIdParam ?? text).trim()
const match = candidate.match(/^(\d+)[:\-](\d+)$/)
if (!match) return null
return `${match[1]}:${match[2]}`
}
export function sanitizeFilePart(value: string): string {
return value.replace(/[^a-z0-9\u4e00-\u9fa5_-]+/gi, "-").replace(/^-|-$/g, "") || "node"
}
function safeDecodeURIComponent(value: string): string {
try {
return decodeURIComponent(value)
} catch {
return value
}
}

16
tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}

10
tsup.config.ts Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from "tsup"
export default defineConfig({
entry: ["src/bin.ts", "src/index.ts"],
format: ["esm"],
target: "node20",
dts: true,
sourcemap: true,
clean: true
})