mirror of
https://github.com/KwiTsukasa/figma-local-context-mcp.git
synced 2026-05-27 16:45:46 +08:00
Initial commit
This commit is contained in:
commit
7c510c737c
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.env
|
||||
.DS_Store
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
155
README.md
Normal 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 或 PNG,PNG 支持倍率。
|
||||
|
||||
## 示例参数
|
||||
|
||||
获取官方风格设计上下文:
|
||||
|
||||
```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
59
package.json
Normal 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
2143
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
19
server.json
Normal file
19
server.json
Normal 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
28
src/bin.ts
Normal 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
12
src/index.ts
Normal 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
247
src/mcp/index.ts
Normal 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
17
src/server.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
276
src/services/design-context.ts
Normal file
276
src/services/design-context.ts
Normal 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()
|
||||
}
|
||||
67
src/services/export-node.ts
Normal file
67
src/services/export-node.ts
Normal 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
138
src/services/fig-file.ts
Normal 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
|
||||
}))
|
||||
}
|
||||
541
src/services/fig-node-svg.ts
Normal file
541
src/services/fig-node-svg.ts
Normal 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, """)
|
||||
}
|
||||
|
||||
function format(value: number): string {
|
||||
return Number.isInteger(value) ? String(value) : Number(value.toFixed(4)).toString()
|
||||
}
|
||||
89
src/services/fig-types.ts
Normal file
89
src/services/fig-types.ts
Normal 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
104
src/services/fig2json.ts
Normal 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
30
src/utils/node-id.ts
Normal 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
16
tsconfig.json
Normal 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
10
tsup.config.ts
Normal 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
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user