完善本地 Figma PNG 导出拟真能力

This commit is contained in:
sunlei 2026-05-24 14:37:56 +08:00
parent e249b5d652
commit 21198de5c2
6 changed files with 892 additions and 72 deletions

View File

@ -13,7 +13,7 @@
- `get_design_tokens`:从全文件或指定节点子树中推导颜色、渐变、阴影、描边 token。 - `get_design_tokens`:从全文件或指定节点子树中推导颜色、渐变、阴影、描边 token。
- `inspect_fig_file`:读取本地 `.fig`,返回节点数量、类型统计和节点概览。 - `inspect_fig_file`:读取本地 `.fig`,返回节点数量、类型统计和节点概览。
- `get_fig_node`:按节点名、`1234:5678`、`1234-5678`、`node-id=1234-5678` 或完整 Figma 链接查找节点,并返回简化上下文。 - `get_fig_node`:按节点名、`1234:5678`、`1234-5678`、`node-id=1234-5678` 或完整 Figma 链接查找节点,并返回简化上下文。
- `export_fig_node`:把指定节点导出为 SVG 或 PNGPNG 支持倍率。 - `export_fig_node`:把指定节点导出为 SVG 或 PNGPNG 支持倍率,默认走本地 `figma-like` 渲染。它会在本 MCP 生成的 SVG 上对部分滤镜/内阴影做补偿后再用 `@resvg/resvg-js` 栅格化,目标是接近 Figma 在线端 PNG但不等同于 Figma 原生 PNG 导出
## 示例参数 ## 示例参数
@ -97,10 +97,24 @@
"nodeQuery": "1234-5678", "nodeQuery": "1234-5678",
"outputPath": "C:\\Users\\you\\Exports\\figma-local-context\\sample-node.png", "outputPath": "C:\\Users\\you\\Exports\\figma-local-context\\sample-node.png",
"format": "png", "format": "png",
"scale": 2 "scale": 2,
"pngRenderer": "figma-like"
} }
``` ```
导出结果中的 `renderer` 字段会标明导出管线:
- `local-svg`:直接写出本地解码生成的 SVG。
- `local-figma-like-resvg`:默认 PNG 管线。先生成带 Figma-like 滤镜补偿的本地 SVG再用 `@resvg/resvg-js` 转 PNG。
- `local-svg-resvg`:普通预览 PNG 管线。先生成本地 SVG再用 `@resvg/resvg-js` 转 PNG。Figma 在线端 PNG 使用自身渲染管线,不能假定它是在线 SVG 再转 PNG。
导出结果还会包含 `exportCapabilities`
- `localSvg.supported: true`:支持从本地 `.fig` 解码生成 SVG。
- `localPng.supported: true`:支持把本地 SVG 栅格化成预览 PNG。
- `figmaLikePng.supported: true`:支持本地 Figma-like PNG 近似渲染,会对 Figma 原生 PNG 中更强的内阴影/透明边缘做补偿。
- `figmaNativePng.supported: false`:纯本地 `.fig` 解码无法调用 Figma 原生 PNG 渲染器;如果需要与 Figma 在线 PNG 像素级一致,需要接入 Figma 官方运行时/API/桌面端导出能力。
## 开发 ## 开发
```bash ```bash

View File

@ -13,7 +13,8 @@ import { getFigNodeContext, inspectFigFile } from "../services/fig-file.js"
const serverInfo = { const serverInfo = {
name: "Figma Local Context MCP", name: "Figma Local Context MCP",
version: process.env.NPM_PACKAGE_VERSION ?? "0.1.0", version: process.env.NPM_PACKAGE_VERSION ?? "0.1.0",
description: "Read local .fig files, inspect node context, and export selected nodes without Figma API." description:
"Read local .fig files, inspect node context, export local SVG, and render Figma-like PNGs without Figma API/native renderer."
} }
const inspectParams = z.object({ const inspectParams = z.object({
@ -37,9 +38,13 @@ const exportNodeParams = z.object({
.min(1) .min(1)
.describe("节点名称、2625:12945、2625-12945、node-id=2625-12945 或完整 Figma 链接。"), .describe("节点名称、2625:12945、2625-12945、node-id=2625-12945 或完整 Figma 链接。"),
outputPath: z.string().min(1).optional().describe("输出文件路径;不传时写到当前工作目录。"), outputPath: z.string().min(1).optional().describe("输出文件路径;不传时写到当前工作目录。"),
format: z.enum(["svg", "png"]).default("png").describe("导出格式。"), format: z.enum(["svg", "png"]).default("png").describe("导出格式png 默认使用本地 figma-like 渲染。"),
scale: z.number().positive().max(10).default(2).describe("导出倍率PNG 常用 1/2/3/4。"), scale: z.number().positive().max(10).default(2).describe("导出倍率PNG 预览常用 1/2/3/4。"),
background: z.string().optional().describe("可选背景色,例如 #ffffff。") background: z.string().optional().describe("可选背景色,例如 #ffffff。"),
pngRenderer: z
.enum(["figma-like", "local-preview"])
.default("figma-like")
.describe("PNG 渲染器figma-like 会增强滤镜/内阴影以接近 Figma PNGlocal-preview 是普通 SVG 栅格化。")
}) })
const listNodesParams = z.object({ const listNodesParams = z.object({
@ -78,9 +83,13 @@ const exportAssetsParams = z.object({
.max(100) .max(100)
.describe("要导出的节点名或 node-id 列表。"), .describe("要导出的节点名或 node-id 列表。"),
outputDir: z.string().min(1).describe("资源输出目录。"), outputDir: z.string().min(1).describe("资源输出目录。"),
format: z.enum(["svg", "png"]).default("png").describe("导出格式。"), format: z.enum(["svg", "png"]).default("png").describe("导出格式png 默认使用本地 figma-like 渲染。"),
scale: z.number().positive().max(10).default(2).describe("导出倍率PNG 常用 1/2/3/4。"), scale: z.number().positive().max(10).default(2).describe("导出倍率PNG 预览常用 1/2/3/4。"),
background: z.string().optional().describe("可选背景色,例如 #ffffff。") background: z.string().optional().describe("可选背景色,例如 #ffffff。"),
pngRenderer: z
.enum(["figma-like", "local-preview"])
.default("figma-like")
.describe("PNG 渲染器figma-like 会增强滤镜/内阴影以接近 Figma PNGlocal-preview 是普通 SVG 栅格化。")
}) })
const designTokensParams = z.object({ const designTokensParams = z.object({
@ -161,7 +170,7 @@ export function createServer(): McpServer {
"export_assets", "export_assets",
{ {
title: "Export design assets", title: "Export design assets",
description: "批量导出本地 .fig/.fig.json 中的多个节点为 SVG 或 PNG。", description: "批量导出本地 .fig/.fig.json 中的多个节点为 SVG 或本地 figma-like PNG。",
inputSchema: exportAssetsParams, inputSchema: exportAssetsParams,
annotations: { readOnlyHint: false, openWorldHint: true } annotations: { readOnlyHint: false, openWorldHint: true }
}, },
@ -173,7 +182,8 @@ export function createServer(): McpServer {
outputDir: params.outputDir, outputDir: params.outputDir,
format: params.format, format: params.format,
scale: params.scale, scale: params.scale,
background: params.background background: params.background,
pngRenderer: params.pngRenderer
}) })
) )
) )
@ -215,7 +225,7 @@ export function createServer(): McpServer {
"export_fig_node", "export_fig_node",
{ {
title: "Export local Figma node", title: "Export local Figma node",
description: "把本地 .fig/.fig.json 中的指定节点导出为 SVG 或 PNG。", description: "把本地 .fig/.fig.json 中的指定节点导出为 SVG 或本地 figma-like PNG。",
inputSchema: exportNodeParams, inputSchema: exportNodeParams,
annotations: { readOnlyHint: false, openWorldHint: true } annotations: { readOnlyHint: false, openWorldHint: true }
}, },
@ -227,7 +237,8 @@ export function createServer(): McpServer {
outputPath: params.outputPath, outputPath: params.outputPath,
format: params.format, format: params.format,
scale: params.scale, scale: params.scale,
background: params.background background: params.background,
pngRenderer: params.pngRenderer
}) })
) )
) )

View File

@ -1,6 +1,6 @@
import path from "node:path" import path from "node:path"
import type { FigColor, FigJson, FigNode, FigPaint } from "./fig-types.js" import type { FigColor, FigJson, FigNode, FigPaint } from "./fig-types.js"
import { exportFigNode, type ExportNodeResult } from "./export-node.js" import { exportFigNode, type ExportNodeResult, type PngRenderer } from "./export-node.js"
import { getChildrenByParent, findTargetNode } from "./fig-node-svg.js" import { getChildrenByParent, findTargetNode } from "./fig-node-svg.js"
import { getRootNode, loadFigFile, serializeNode, summarizeNode } from "./fig-file.js" import { getRootNode, loadFigFile, serializeNode, summarizeNode } from "./fig-file.js"
import { keyForGuid, sanitizeFilePart } from "../utils/node-id.js" import { keyForGuid, sanitizeFilePart } from "../utils/node-id.js"
@ -33,6 +33,7 @@ export type ExportAssetsOptions = {
format: "svg" | "png" format: "svg" | "png"
scale: number scale: number
background?: string background?: string
pngRenderer?: PngRenderer
} }
export function listFigNodes(filePath: string, options: NodeListOptions) { export function listFigNodes(filePath: string, options: NodeListOptions) {
@ -102,7 +103,8 @@ export function exportAssets(options: ExportAssetsOptions) {
outputPath: path.join(options.outputDir, fileName), outputPath: path.join(options.outputDir, fileName),
format: options.format, format: options.format,
scale: options.scale, scale: options.scale,
background: options.background background: options.background,
pngRenderer: options.pngRenderer
}) })
) )
} }
@ -159,7 +161,9 @@ function buildCodeHints(node: FigNode, childrenByParent: Map<string, FigNode[]>,
}, },
exportHint: exportHint:
node.type === "VECTOR" || node.type === "ELLIPSE" || node.type === "FRAME" 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.` ? `Use export_fig_node or export_assets with nodeQuery "${keyForGuid(
node.guid
)}". SVG is structural local output; PNG is local SVG rasterization, not Figma native PNG export.`
: undefined : undefined
} }

View File

@ -1,10 +1,15 @@
import fs from "node:fs" import fs from "node:fs"
import path from "node:path" import path from "node:path"
import zlib from "node:zlib"
import { Resvg } from "@resvg/resvg-js" import { Resvg } from "@resvg/resvg-js"
import { loadFigFile } from "./fig-file.js" import { loadFigFile } from "./fig-file.js"
import { renderNodeToSvg } from "./fig-node-svg.js" import { renderNodeToSvg, type FigmaLikeRasterHint } from "./fig-node-svg.js"
import { keyForGuid, sanitizeFilePart } from "../utils/node-id.js" import { keyForGuid, sanitizeFilePart } from "../utils/node-id.js"
export type PngRenderer = "figma-like" | "local-preview"
const MASK_SUPERSAMPLE = 8
export type ExportNodeOptions = { export type ExportNodeOptions = {
filePath: string filePath: string
nodeQuery: string nodeQuery: string
@ -12,6 +17,7 @@ export type ExportNodeOptions = {
format: "svg" | "png" format: "svg" | "png"
scale: number scale: number
background?: string background?: string
pngRenderer?: PngRenderer
} }
export type ExportNodeResult = { export type ExportNodeResult = {
@ -21,6 +27,31 @@ export type ExportNodeResult = {
scale: number scale: number
width: number width: number
height: number height: number
renderer: "local-svg" | "local-svg-resvg" | "local-figma-like-resvg"
exportCapabilities: {
localSvg: {
supported: true
renderer: "figma-local-context-mcp"
source: "decoded-local-fig"
}
localPng: {
supported: true
renderer: "@resvg/resvg-js"
source: "mcp-generated-svg"
fidelity: "preview"
}
figmaLikePng: {
supported: true
renderer: "@resvg/resvg-js"
source: "mcp-generated-svg-with-figma-like-filter-compensation"
fidelity: "approximate"
}
figmaNativePng: {
supported: false
reason: string
}
}
warnings?: string[]
node: { node: {
id: string id: string
name?: string name?: string
@ -30,19 +61,45 @@ export type ExportNodeResult = {
export function exportFigNode(options: ExportNodeOptions): ExportNodeResult { export function exportFigNode(options: ExportNodeOptions): ExportNodeResult {
const figJson = loadFigFile(options.filePath) const figJson = loadFigFile(options.filePath)
const pngRenderer = options.pngRenderer ?? "figma-like"
const useFigmaLikePng = options.format === "png" && pngRenderer === "figma-like"
const rendered = renderNodeToSvg(figJson, { const rendered = renderNodeToSvg(figJson, {
nodeQuery: options.nodeQuery, nodeQuery: options.nodeQuery,
scale: options.scale, scale: options.scale,
background: options.background background: options.background,
pngFigmaLike: useFigmaLikePng
}) })
const outputPath = path.resolve(options.outputPath ?? defaultOutputPath(options)) const outputPath = path.resolve(options.outputPath ?? defaultOutputPath(options))
const node = {
id: keyForGuid(rendered.node.guid),
name: rendered.node.name,
type: rendered.node.type
}
fs.mkdirSync(path.dirname(outputPath), { recursive: true }) fs.mkdirSync(path.dirname(outputPath), { recursive: true })
if (options.format === "svg") { if (options.format === "svg") {
fs.writeFileSync(outputPath, rendered.svg) fs.writeFileSync(outputPath, rendered.svg)
} else { } else {
fs.writeFileSync(outputPath, new Resvg(rendered.svg).render().asPng()) const image = new Resvg(rendered.svg).render()
if (useFigmaLikePng && rendered.rasterHints.length) {
const pixels = Buffer.from(image.pixels)
unpremultiplyPixels(pixels)
applyFigmaLikeRasterHints(pixels, rendered.width, rendered.height, rendered.viewBox, rendered.rasterHints)
fs.writeFileSync(outputPath, encodeRgbaPng(rendered.width, rendered.height, pixels))
} else {
fs.writeFileSync(outputPath, image.asPng())
} }
}
const warnings =
options.format === "png"
? [
pngRenderer === "figma-like"
? "PNG 使用本地 figma-like 渲染:会对 Figma 内阴影/透明边缘做近似补偿,但仍不保证与 Figma 原生 PNG 像素级完全一致。"
: "PNG 使用本地 SVG + @resvg/resvg-js 预览渲染,不保证与 Figma 原生 PNG 一致。"
]
: undefined
return { return {
filePath: path.resolve(options.filePath), filePath: path.resolve(options.filePath),
@ -51,10 +108,36 @@ export function exportFigNode(options: ExportNodeOptions): ExportNodeResult {
scale: options.scale, scale: options.scale,
width: rendered.width, width: rendered.width,
height: rendered.height, height: rendered.height,
node: { renderer: options.format === "svg" ? "local-svg" : useFigmaLikePng ? "local-figma-like-resvg" : "local-svg-resvg",
id: keyForGuid(rendered.node.guid), exportCapabilities: getExportCapabilities(),
name: rendered.node.name, warnings,
type: rendered.node.type node
}
}
function getExportCapabilities(): ExportNodeResult["exportCapabilities"] {
return {
localSvg: {
supported: true,
renderer: "figma-local-context-mcp",
source: "decoded-local-fig"
},
localPng: {
supported: true,
renderer: "@resvg/resvg-js",
source: "mcp-generated-svg",
fidelity: "preview"
},
figmaLikePng: {
supported: true,
renderer: "@resvg/resvg-js",
source: "mcp-generated-svg-with-figma-like-filter-compensation",
fidelity: "approximate"
},
figmaNativePng: {
supported: false,
reason:
"Figma native PNG export uses Figma's own renderer. This MCP implements a local figma-like approximation instead of calling Figma."
} }
} }
} }
@ -65,3 +148,231 @@ function defaultOutputPath(options: ExportNodeOptions): string {
const scalePart = options.format === "png" ? `@${options.scale}x` : "" const scalePart = options.format === "png" ? `@${options.scale}x` : ""
return path.join(process.cwd(), `${input.name}-${nodePart}${scalePart}.${options.format}`) return path.join(process.cwd(), `${input.name}-${nodePart}${scalePart}.${options.format}`)
} }
function applyFigmaLikeRasterHints(
pixels: Buffer,
width: number,
height: number,
viewBox: { minX: number; minY: number; maxX: number; maxY: number },
hints: FigmaLikeRasterHint[]
) {
const sx = width / (viewBox.maxX - viewBox.minX)
const sy = height / (viewBox.maxY - viewBox.minY)
for (const hint of hints) {
if (hint.type !== "ellipse-inner-shadow") continue
const inverse = invertMatrix(hint.matrix)
const color = {
r: toByte(hint.color.r),
g: toByte(hint.color.g),
b: toByte(hint.color.b)
}
const masks = createEllipseInnerShadowMasks(width, height, viewBox, sx, sy, hint, inverse)
const blurScale = (Math.abs(sx) + Math.abs(sy)) / 2
const blurredErodedMask = gaussianBlurMask(masks.erodedMask, width, height, Math.max(0.5, hint.blurSigma * blurScale))
for (let index = 0; index < masks.hardMask.length; index += 1) {
if (masks.hardMask[index] === 0) continue
const shadowCoverage = Math.max(0, masks.hardMask[index] - blurredErodedMask[index])
const shadowAlpha = Math.min(1, shadowCoverage * hint.opacity)
if (shadowAlpha <= 0.001) continue
compositePixel(pixels, index * 4, color.r, color.g, color.b, shadowAlpha)
}
}
}
function createEllipseInnerShadowMasks(
width: number,
height: number,
viewBox: { minX: number; minY: number; maxX: number; maxY: number },
sx: number,
sy: number,
hint: FigmaLikeRasterHint,
inverse: readonly [number, number, number, number, number, number]
) {
const hardMask = new Float32Array(width * height)
const erodedMask = new Float32Array(width * height)
const erodedRx = Math.max(0.1, hint.rx - hint.spread)
const erodedRy = Math.max(0.1, hint.ry - hint.spread)
const sampleWeight = 1 / (MASK_SUPERSAMPLE * MASK_SUPERSAMPLE)
// Figma's native PNG export appears to build inner shadow coverage from a
// vector mask, then erode and blur that mask. Subpixel coverage is important:
// a hard pixel-center mask makes shallow ellipse edges visibly stair-step.
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const index = y * width + x
let hardCoverage = 0
let erodedCoverage = 0
for (let sampleY = 0; sampleY < MASK_SUPERSAMPLE; sampleY += 1) {
const worldY = viewBox.minY + (y + (sampleY + 0.5) / MASK_SUPERSAMPLE) / sy
for (let sampleX = 0; sampleX < MASK_SUPERSAMPLE; sampleX += 1) {
const worldX = viewBox.minX + (x + (sampleX + 0.5) / MASK_SUPERSAMPLE) / sx
const local = applyMatrix(inverse, worldX, worldY)
const normalizedX = (local.x - hint.cx) / hint.rx
const normalizedY = (local.y - hint.cy) / hint.ry
if (normalizedX * normalizedX + normalizedY * normalizedY > 1) continue
hardCoverage += sampleWeight
const erodedX = (local.x - hint.cx) / erodedRx
const erodedY = (local.y - hint.cy) / erodedRy
if (erodedX * erodedX + erodedY * erodedY <= 1) {
erodedCoverage += sampleWeight
}
}
}
hardMask[index] = hardCoverage
erodedMask[index] = erodedCoverage
}
}
return { hardMask, erodedMask }
}
function gaussianBlurMask(mask: Float32Array, width: number, height: number, sigma: number): Float32Array {
if (sigma <= 0.01) return mask
const kernel = createGaussianKernel(sigma)
const radius = (kernel.length - 1) / 2
const temp = new Float32Array(mask.length)
const output = new Float32Array(mask.length)
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
let value = 0
for (let offset = -radius; offset <= radius; offset += 1) {
const sampleX = Math.max(0, Math.min(width - 1, x + offset))
value += mask[y * width + sampleX] * kernel[offset + radius]
}
temp[y * width + x] = value
}
}
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
let value = 0
for (let offset = -radius; offset <= radius; offset += 1) {
const sampleY = Math.max(0, Math.min(height - 1, y + offset))
value += temp[sampleY * width + x] * kernel[offset + radius]
}
output[y * width + x] = value
}
}
return output
}
function createGaussianKernel(sigma: number): number[] {
const radius = Math.max(1, Math.ceil(sigma * 3))
const kernel: number[] = []
let sum = 0
for (let offset = -radius; offset <= radius; offset += 1) {
const value = Math.exp(-(offset * offset) / (2 * sigma * sigma))
kernel.push(value)
sum += value
}
return kernel.map((value) => value / sum)
}
function unpremultiplyPixels(pixels: Buffer) {
for (let offset = 0; offset < pixels.length; offset += 4) {
const alpha = pixels[offset + 3]
if (alpha === 0 || alpha === 255) continue
pixels[offset] = Math.min(255, Math.round((pixels[offset] * 255) / alpha))
pixels[offset + 1] = Math.min(255, Math.round((pixels[offset + 1] * 255) / alpha))
pixels[offset + 2] = Math.min(255, Math.round((pixels[offset + 2] * 255) / alpha))
}
}
function compositePixel(pixels: Buffer, offset: number, sourceR: number, sourceG: number, sourceB: number, sourceA: number) {
const destA = pixels[offset + 3] / 255
const outA = sourceA + destA * (1 - sourceA)
if (outA <= 0) return
pixels[offset] = Math.round((sourceR * sourceA + pixels[offset] * destA * (1 - sourceA)) / outA)
pixels[offset + 1] = Math.round((sourceG * sourceA + pixels[offset + 1] * destA * (1 - sourceA)) / outA)
pixels[offset + 2] = Math.round((sourceB * sourceA + pixels[offset + 2] * destA * (1 - sourceA)) / outA)
pixels[offset + 3] = Math.round(outA * 255)
}
function encodeRgbaPng(width: number, height: number, pixels: Buffer): Buffer {
const stride = width * 4
const raw = Buffer.alloc((stride + 1) * height)
for (let y = 0; y < height; y += 1) {
const rowOffset = y * (stride + 1)
raw[rowOffset] = 0
pixels.copy(raw, rowOffset + 1, y * stride, (y + 1) * stride)
}
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
pngChunk("IHDR", createIhdr(width, height)),
pngChunk("IDAT", zlib.deflateSync(raw)),
pngChunk("IEND", Buffer.alloc(0))
])
}
function createIhdr(width: number, height: number): Buffer {
const data = Buffer.alloc(13)
data.writeUInt32BE(width, 0)
data.writeUInt32BE(height, 4)
data[8] = 8
data[9] = 6
data[10] = 0
data[11] = 0
data[12] = 0
return data
}
function pngChunk(type: string, data: Buffer): Buffer {
const typeBuffer = Buffer.from(type)
const length = Buffer.alloc(4)
length.writeUInt32BE(data.length, 0)
const crc = Buffer.alloc(4)
crc.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 0)
return Buffer.concat([length, typeBuffer, data, crc])
}
function crc32(buffer: Buffer): number {
let crc = 0xffffffff
for (const byte of buffer) {
crc ^= byte
for (let bit = 0; bit < 8; bit += 1) {
crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1
}
}
return (crc ^ 0xffffffff) >>> 0
}
function invertMatrix(matrix: [number, number, number, number, number, number]) {
const [a, b, c, d, e, f] = matrix
const determinant = a * d - b * c
if (Math.abs(determinant) < Number.EPSILON) return [1, 0, 0, 1, 0, 0] as const
return [
d / determinant,
-b / determinant,
-c / determinant,
a / determinant,
(c * f - d * e) / determinant,
(b * e - a * f) / determinant
] as const
}
function applyMatrix(matrix: readonly [number, number, number, number, number, number], 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 toByte(value: number): number {
return Math.round(Math.max(0, Math.min(1, value)) * 255)
}

View File

@ -22,8 +22,24 @@ type RenderContext = {
figJson: FigJson figJson: FigJson
childrenByParent: Map<string, FigNode[]> childrenByParent: Map<string, FigNode[]>
defs: string[] defs: string[]
rasterHints: FigmaLikeRasterHint[]
bounds: Bounds | null bounds: Bounds | null
effectBounds: Bounds | null
idSeed: number idSeed: number
pngFigmaLike: boolean
}
export type FigmaLikeRasterHint = {
type: "ellipse-inner-shadow"
matrix: SvgMatrix
cx: number
cy: number
rx: number
ry: number
color: FigColor
opacity: number
spread: number
blurSigma: number
} }
export type RenderOptions = { export type RenderOptions = {
@ -32,6 +48,7 @@ export type RenderOptions = {
nodeQuery?: string nodeQuery?: string
scale?: number scale?: number
background?: string background?: string
pngFigmaLike?: boolean
} }
export type RenderedSvg = { export type RenderedSvg = {
@ -39,6 +56,7 @@ export type RenderedSvg = {
width: number width: number
height: number height: number
viewBox: Bounds viewBox: Bounds
rasterHints: FigmaLikeRasterHint[]
node: FigNode node: FigNode
} }
@ -55,29 +73,48 @@ export function renderNodeToSvg(figJson: FigJson, options: RenderOptions): Rende
figJson, figJson,
childrenByParent, childrenByParent,
defs: [], defs: [],
rasterHints: [],
bounds: null, bounds: null,
idSeed: 0 effectBounds: null,
idSeed: 0,
pngFigmaLike: options.pngFigmaLike ?? false
} }
const body = renderNodeSubtree(context, target, IDENTITY, true) 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 bounds = getRootExportBounds(target, context.bounds, context.effectBounds)
const width = bounds.maxX - bounds.minX const width = bounds.maxX - bounds.minX
const height = bounds.maxY - bounds.minY const height = bounds.maxY - bounds.minY
const scale = options.scale ?? 2 const scale = options.scale ?? 2
const pixelWidth = getFigmaPixelSize(width, scale)
const pixelHeight = getFigmaPixelSize(height, scale)
// Figma exports selected nodes at ceil(size * scale) without stretching the
// artwork; extra fractional pixels become transparent canvas at the edge.
const viewBox = {
minX: bounds.minX,
minY: bounds.minY,
maxX: bounds.minX + pixelWidth / scale,
maxY: bounds.minY + pixelHeight / scale
}
const viewBoxWidth = viewBox.maxX - viewBox.minX
const viewBoxHeight = viewBox.maxY - viewBox.minY
const background = options.background const background = options.background
? `<rect x="${format(bounds.minX)}" y="${format(bounds.minY)}" width="${format(width)}" height="${format(height)}" fill="${escapeAttribute(options.background)}"/>` ? `<rect x="${format(viewBox.minX)}" y="${format(viewBox.minY)}" width="${format(
viewBoxWidth
)}" height="${format(viewBoxHeight)}" fill="${escapeAttribute(options.background)}"/>`
: "" : ""
const svg = [ const svg = [
`<svg xmlns="http://www.w3.org/2000/svg" width="${format(width * scale)}" height="${format( `<svg xmlns="http://www.w3.org/2000/svg" width="${format(pixelWidth)}" height="${format(
height * scale pixelHeight
)}" viewBox="${format(bounds.minX)} ${format(bounds.minY)} ${format(width)} ${format(height)}" shape-rendering="geometricPrecision">`, )}" viewBox="${format(viewBox.minX)} ${format(viewBox.minY)} ${format(viewBoxWidth)} ${format(
viewBoxHeight
)}" shape-rendering="geometricPrecision">`,
context.defs.length ? `<defs>${context.defs.join("")}</defs>` : "", context.defs.length ? `<defs>${context.defs.join("")}</defs>` : "",
background, background,
body, body,
"</svg>" "</svg>"
].join("") ].join("")
return { svg, width: width * scale, height: height * scale, viewBox: bounds, node: target } return { svg, width: pixelWidth, height: pixelHeight, viewBox, rasterHints: context.rasterHints, node: target }
} }
export function findTargetNode(figJson: FigJson, options: RenderOptions): FigNode { export function findTargetNode(figJson: FigJson, options: RenderOptions): FigNode {
@ -120,29 +157,200 @@ function renderNodeSubtree(
const localMatrix = isRoot ? IDENTITY : toSvgMatrix(node.transform) const localMatrix = isRoot ? IDENTITY : toSvgMatrix(node.transform)
const matrix = multiply(parentMatrix, localMatrix) const matrix = multiply(parentMatrix, localMatrix)
// Boolean-operation children are construction geometry. Figma renders the
// computed boolean path, not the source shapes again.
const shouldRenderChildren =
node.type !== "BOOLEAN_OPERATION" || !(node.fillGeometry?.length || node.strokeGeometry?.length)
const nodeContent = [ const nodeContent = [
...renderGeometry(context, node, node.fillGeometry, node.fillPaints, matrix), ...renderGeometry(context, node, node.fillGeometry, node.fillPaints, matrix),
collectFigmaLikeEllipseInnerShadowHint(context, node, matrix),
...renderStrokeGeometry(context, node, matrix), ...renderStrokeGeometry(context, node, matrix),
...getSortedChildren(context, node).map((child) => renderNodeSubtree(context, child, matrix)) ...(shouldRenderChildren ? getSortedChildren(context, node).map((child) => renderNodeSubtree(context, child, matrix)) : [])
].join("") ].join("")
if (!nodeContent) return "" if (!nodeContent) return ""
const transform = matrixToAttribute(localMatrix)
const opacity = node.opacity != null && node.opacity !== 1 ? ` opacity="${format(node.opacity)}"` : "" const opacity = node.opacity != null && node.opacity !== 1 ? ` opacity="${format(node.opacity)}"` : ""
const filterId = createNodeEffectFilter(context, node, matrix) const filterId = createNodeEffectFilter(context, node, matrix)
const filter = filterId ? ` filter="url(#${filterId})"` : "" const filter = filterId ? ` filter="url(#${filterId})"` : ""
return `<g${transform}${opacity}${filter}>${nodeContent}</g>` return `<g${opacity}${filter}>${nodeContent}</g>`
} }
function renderStrokeGeometry(context: RenderContext, node: FigNode, matrix: SvgMatrix): string[] { function renderStrokeGeometry(context: RenderContext, node: FigNode, matrix: SvgMatrix): string[] {
const outsideEllipseStroke = renderOutsideEllipseStroke(context, node, matrix) const outsideEllipseStroke = renderOutsideEllipseStroke(context, node, matrix)
if (outsideEllipseStroke) return outsideEllipseStroke if (outsideEllipseStroke) return outsideEllipseStroke
const pathStroke = renderPathStroke(context, node, matrix)
if (pathStroke) return pathStroke
return renderGeometry(context, node, node.strokeGeometry, node.strokePaints, matrix) return renderGeometry(context, node, node.strokeGeometry, node.strokePaints, matrix)
} }
function renderPathStroke(context: RenderContext, node: FigNode, matrix: SvgMatrix): string[] | null {
const strokeWeight = node.strokeWeight ?? 0
if (!node.fillGeometry?.length || !node.strokePaints?.length || strokeWeight <= 0) return null
const visibleStrokePaints = node.strokePaints.filter((paint) => paint.visible !== false)
const hasGradientStroke = visibleStrokePaints.some((paint) => paint.type === "GRADIENT_LINEAR")
if (visibleStrokePaints.some((paint) => paint.type !== "SOLID" && paint.type !== "GRADIENT_LINEAR")) return null
if (hasGradientStroke && !shouldRenderGradientStrokeAsPath(node)) return null
return node.fillGeometry.flatMap((geometry) => {
const parsed = parsePathBlob(context.figJson, geometry.commandsBlob)
const expandedBounds = expandBounds(parsed.bounds, strokeWeight / 2)
includeBounds(context, transformBounds(matrix, expandedBounds))
const strokeMatrix = multiply(matrix, getStrokeAlignmentMatrix(parsed.bounds, strokeWeight, node.strokeAlign))
const insetMiterPath = createInsetMiterStrokePath(parsed.d, strokeWeight, node.strokeAlign, matrix)
// Figma's SVG export keeps normal vector strokes as stroke attributes. The
// decoded strokeGeometry is an expanded outline for internal raster use; if
// we fill it directly, thin isometric edges become much too thick.
return visibleStrokePaints
.map((paint) => {
const stroke = paintToSvgFill(context, node, parsed.bounds, paint, matrix)
const opacity = paintOpacityAttribute("stroke", paint)
const dashArray = strokeDashArrayAttribute(node)
return `<path d="${insetMiterPath ?? transformPathData(parsed.d, strokeMatrix)}" fill="none" stroke="${stroke}" stroke-width="${format(
strokeWeight
)}"${opacity}${dashArray}/>`
})
})
}
function shouldRenderGradientStrokeAsPath(node: FigNode): boolean {
return Boolean(node.type === "ELLIPSE" && node.size && isFullEllipse(node.arcData))
}
function createInsetMiterStrokePath(
d: string,
strokeWeight: number,
strokeAlign: FigNode["strokeAlign"] | undefined,
matrix: SvgMatrix
): string | null {
if (strokeAlign !== "INSIDE") return null
const segments = getStraightSegments(d)
if (segments.length !== 4) return null
const area = getSignedArea(segments.map((segment) => segment.from))
const offsetLines = segments.map((segment) => offsetLine(segment.from, segment.to, strokeWeight / 2, area >= 0))
const intersections = offsetLines.map((line, index) => intersectLines(offsetLines[(index + 3) % 4], line))
if (intersections.some((point) => !point)) return null
const points = [intersections[1]!, intersections[2]!, intersections[3]!, intersections[0]!].map((point) =>
applyToPoint(matrix, point.x, point.y)
)
// For small rounded diamond strokes, Figma's SVG export collapses the side
// corner arcs into mitered points. Rebuilding that centerline avoids the flat
// caps created by a simple scaled copy of the fill path.
return `M ${format(points[0].x)} ${format(points[0].y)} L ${format(points[1].x)} ${format(points[1].y)} L ${format(
points[2].x
)} ${format(points[2].y)} L ${format(points[3].x)} ${format(points[3].y)} Z`
}
function getStraightSegments(d: string): Array<{ from: { x: number; y: number }; to: { x: number; y: number } }> {
const tokens = d.match(/[MLCZ]|-?\d*\.?\d+(?:e[-+]?\d+)?/gi) ?? []
const segments: Array<{ from: { x: number; y: number }; to: { x: number; y: number } }> = []
let index = 0
let current: { x: number; y: number } | null = null
const readPoint = () => ({ x: Number(tokens[index++]), y: Number(tokens[index++]) })
while (index < tokens.length) {
const command = tokens[index++]
if (command === "M") {
current = readPoint()
} else if (command === "L" && current) {
const next = readPoint()
segments.push({ from: current, to: next })
current = next
} else if (command === "C") {
index += 4
current = readPoint()
} else if (command === "Z") {
break
}
}
const first = segments[0]
const last = segments[segments.length - 1]
if (first && last && getPointDistance(first.from, last.to) < 0.001) {
segments.pop()
}
return segments
}
function getPointDistance(first: { x: number; y: number }, second: { x: number; y: number }): number {
return Math.hypot(first.x - second.x, first.y - second.y)
}
function getSignedArea(points: Array<{ x: number; y: number }>): number {
let area = 0
for (let index = 0; index < points.length; index += 1) {
const current = points[index]
const next = points[(index + 1) % points.length]
area += current.x * next.y - next.x * current.y
}
return area / 2
}
function offsetLine(from: { x: number; y: number }, to: { x: number; y: number }, distance: number, ccw: boolean) {
const dx = to.x - from.x
const dy = to.y - from.y
const length = Math.hypot(dx, dy)
if (length < Number.EPSILON) return { from, dx, dy }
const normal = ccw ? { x: -dy / length, y: dx / length } : { x: dy / length, y: -dx / length }
return {
from: { x: from.x + normal.x * distance, y: from.y + normal.y * distance },
dx,
dy
}
}
function intersectLines(
first: { from: { x: number; y: number }; dx: number; dy: number },
second: { from: { x: number; y: number }; dx: number; dy: number }
): { x: number; y: number } | null {
const determinant = first.dx * second.dy - first.dy * second.dx
if (Math.abs(determinant) < Number.EPSILON) return null
const t =
((second.from.x - first.from.x) * second.dy - (second.from.y - first.from.y) * second.dx) / determinant
return {
x: first.from.x + first.dx * t,
y: first.from.y + first.dy * t
}
}
function getStrokeAlignmentMatrix(bounds: Bounds, strokeWeight: number, strokeAlign?: FigNode["strokeAlign"]): SvgMatrix {
if (strokeAlign !== "INSIDE" && strokeAlign !== "OUTSIDE") return IDENTITY
const width = bounds.maxX - bounds.minX
const height = bounds.maxY - bounds.minY
if (width <= 0 || height <= 0) return IDENTITY
const direction = strokeAlign === "INSIDE" ? -1 : 1
const scaleX = Math.max(0.001, (width + direction * strokeWeight) / width)
const scaleY = Math.max(0.001, (height + direction * strokeWeight) / height)
const centerX = (bounds.minX + bounds.maxX) / 2
const centerY = (bounds.minY + bounds.maxY) / 2
// Figma's SVG export moves inside/outside stroke centerlines instead of
// filling the decoded stroke outline. Scaling around the geometry center is
// a compact approximation that matches isometric icon bases much better.
return [
scaleX,
0,
0,
scaleY,
centerX - centerX * scaleX,
centerY - centerY * scaleY
]
}
function renderOutsideEllipseStroke(context: RenderContext, node: FigNode, matrix: SvgMatrix): string[] | null { function renderOutsideEllipseStroke(context: RenderContext, node: FigNode, matrix: SvgMatrix): string[] | null {
const strokeWeight = node.strokeWeight ?? 0 const strokeWeight = node.strokeWeight ?? 0
if ( if (
@ -167,17 +375,20 @@ function renderOutsideEllipseStroke(context: RenderContext, node: FigNode, matri
return node.strokePaints return node.strokePaints
.filter((paint) => paint.visible !== false) .filter((paint) => paint.visible !== false)
.map((paint) => { .map((paint) => {
const stroke = paintToSvgFill(context, node, bounds, paint) const stroke = paintToSvgFill(context, node, bounds, paint, IDENTITY)
const opacity = paint.opacity != null && paint.opacity !== 1 ? ` stroke-opacity="${format(paint.opacity)}"` : "" const opacity = paintOpacityAttribute("stroke", paint)
const rx = node.size!.x / 2 + strokeWeight / 2 const rx = node.size!.x / 2 + strokeWeight / 2
const ry = node.size!.y / 2 + strokeWeight / 2 const ry = node.size!.y / 2 + strokeWeight / 2
const transform = matrixToAttribute(matrix)
// Figma's strokeGeometry for OUTSIDE ellipses is an expanded filled // Figma's strokeGeometry for OUTSIDE ellipses is an expanded filled
// outline. Using it directly paints inward and erases the ring gap, so // outline. Using it directly paints inward and erases the ring gap, so
// complete ellipses are emitted as actual outside strokes. // complete ellipses are emitted as actual outside strokes.
return `<ellipse cx="${format(node.size!.x / 2)}" cy="${format(node.size!.y / 2)}" rx="${format( return `<ellipse cx="${format(node.size!.x / 2)}" cy="${format(node.size!.y / 2)}" rx="${format(
rx rx
)}" ry="${format(ry)}" fill="none" stroke="${stroke}" stroke-width="${format(strokeWeight)}"${opacity}/>` )}" ry="${format(ry)}" fill="none" stroke="${stroke}" stroke-width="${format(
strokeWeight
)}"${opacity}${transform}/>`
}) })
} }
@ -203,14 +414,15 @@ function renderGeometry(
const parsed = parsePathBlob(context.figJson, geometry.commandsBlob) const parsed = parsePathBlob(context.figJson, geometry.commandsBlob)
const transformedBounds = transformBounds(matrix, parsed.bounds) const transformedBounds = transformBounds(matrix, parsed.bounds)
includeBounds(context, transformedBounds) includeBounds(context, transformedBounds)
const fillRule = geometry.windingRule === "ODD" ? ` fill-rule="evenodd"` : ""
return paints return paints
.filter((paint) => paint.visible !== false) .filter((paint) => paint.visible !== false)
.map((paint) => { .map((paint) => {
const fill = paintToSvgFill(context, node, parsed.bounds, paint) const fill = paintToSvgFill(context, node, parsed.bounds, paint, matrix)
const opacity = paint.opacity != null && paint.opacity !== 1 ? ` fill-opacity="${format(paint.opacity)}"` : "" const opacity = paintOpacityAttribute("fill", paint)
return `<path d="${parsed.d}" fill="${fill}"${opacity}/>` return `<path d="${transformPathData(parsed.d, matrix)}" fill="${fill}"${fillRule}${opacity}/>`
}) })
}) })
} }
@ -261,14 +473,45 @@ function parsePathBlob(figJson: FigJson, blobIndex: number): ParsedPath {
return { d: d.trim(), bounds } return { d: d.trim(), bounds }
} }
function paintToSvgFill(context: RenderContext, node: FigNode, pathBounds: Bounds, paint: FigPaint): string { function transformPathData(d: string, matrix: SvgMatrix): string {
if (matrix === IDENTITY) return d
const tokens = d.match(/[MLCZ]|-?\d*\.?\d+(?:e[-+]?\d+)?/gi) ?? []
const output: string[] = []
let index = 0
const readNumber = () => Number(tokens[index++])
const readPoint = () => applyToPoint(matrix, readNumber(), readNumber())
const writePoint = (point: { x: number; y: number }) => `${format(point.x)} ${format(point.y)}`
while (index < tokens.length) {
const command = tokens[index++]
if (command === "M" || command === "L") {
output.push(`${command} ${writePoint(readPoint())}`)
} else if (command === "C") {
output.push(`C ${writePoint(readPoint())} ${writePoint(readPoint())} ${writePoint(readPoint())}`)
} else if (command === "Z") {
output.push("Z")
}
}
return output.join(" ")
}
function paintToSvgFill(
context: RenderContext,
node: FigNode,
pathBounds: Bounds,
paint: FigPaint,
matrix: SvgMatrix
): string {
if (paint.type === "SOLID") { if (paint.type === "SOLID") {
return colorToCss(paint.color) return colorToCss(paint.color)
} }
if (paint.type === "GRADIENT_LINEAR") { if (paint.type === "GRADIENT_LINEAR") {
const id = nextId(context, "gradient") const id = nextId(context, "gradient")
const gradient = getLinearGradientLine(node, pathBounds, paint) const gradient = getLinearGradientLine(node, pathBounds, paint, matrix)
const stops = paint.stops const stops = paint.stops
?.map( ?.map(
(stop) => (stop) =>
@ -289,7 +532,7 @@ function paintToSvgFill(context: RenderContext, node: FigNode, pathBounds: Bound
throw new Error(`不支持的填充类型:${paint.type}`) throw new Error(`不支持的填充类型:${paint.type}`)
} }
function getLinearGradientLine(node: FigNode, pathBounds: Bounds, paint: FigPaint) { function getLinearGradientLine(node: FigNode, pathBounds: Bounds, paint: FigPaint, matrix: SvgMatrix) {
const width = node.size?.x || pathBounds.maxX - pathBounds.minX const width = node.size?.x || pathBounds.maxX - pathBounds.minX
const height = node.size?.y || pathBounds.maxY - pathBounds.minY const height = node.size?.y || pathBounds.maxY - pathBounds.minY
const originX = pathBounds.minX < 0 ? pathBounds.minX : 0 const originX = pathBounds.minX < 0 ? pathBounds.minX : 0
@ -300,72 +543,235 @@ function getLinearGradientLine(node: FigNode, pathBounds: Bounds, paint: FigPain
// unit gradient space, so SVG needs the inverse projected onto the node box. // unit gradient space, so SVG needs the inverse projected onto the node box.
const start = applyToPoint(inverse, 0, 0) const start = applyToPoint(inverse, 0, 0)
const end = applyToPoint(inverse, 1, 0) const end = applyToPoint(inverse, 1, 0)
const transformedStart = applyToPoint(matrix, originX + start.x * width, originY + start.y * height)
const transformedEnd = applyToPoint(matrix, originX + end.x * width, originY + end.y * height)
return { return {
x1: originX + start.x * width, x1: transformedStart.x,
y1: originY + start.y * height, y1: transformedStart.y,
x2: originX + end.x * width, x2: transformedEnd.x,
y2: originY + end.y * height y2: transformedEnd.y
} }
} }
function createFilter(context: RenderContext, effects: FigEffect[] | undefined, bounds: Bounds): string | null { function createFilter(context: RenderContext, effects: FigEffect[] | undefined, bounds: Bounds): string | null {
const shadow = effects?.find((effect) => effect.visible !== false && effect.type === "DROP_SHADOW") const shadow = effects?.find((effect) => effect.visible !== false && effect.type === "DROP_SHADOW")
if (!shadow) return null const innerShadows = effects?.filter((effect) => effect.visible !== false && effect.type === "INNER_SHADOW") ?? []
const layerBlur = effects?.find(
(effect) => effect.visible !== false && (effect.type === "FOREGROUND_BLUR" || effect.type === "LAYER_BLUR")
)
if (!shadow && !innerShadows.length && !layerBlur) return null
const id = nextId(context, "shadow") const id = nextId(context, "shadow")
const radius = shadow.radius ?? 0 const shadowRadius = shadow?.radius ?? 0
const spread = shadow.spread ?? 0 const blurRadius = layerBlur?.radius ?? 0
const offsetX = shadow.offset?.x ?? 0 const spread = shadow?.spread ?? 0
const offsetY = shadow.offset?.y ?? 0 const offsetX = shadow?.offset?.x ?? 0
const x = bounds.minX + Math.min(0, offsetX) - radius - spread const offsetY = shadow?.offset?.y ?? 0
const y = bounds.minY + Math.min(0, offsetY) - radius - spread const filterPadding = Math.max(shadowRadius + Math.abs(spread), blurRadius)
const width = bounds.maxX - bounds.minX + Math.abs(offsetX) + radius * 2 + spread * 2 const x = bounds.minX + Math.min(0, offsetX) - filterPadding
const height = bounds.maxY - bounds.minY + Math.abs(offsetY) + radius * 2 + spread * 2 const y = bounds.minY + Math.min(0, offsetY) - filterPadding
const sourceAlpha = spread const width = bounds.maxX - bounds.minX + Math.abs(offsetX) + filterPadding * 2
const height = bounds.maxY - bounds.minY + Math.abs(offsetY) + filterPadding * 2
const sourceAlpha = shadow && spread
? `<feMorphology in="SourceAlpha" operator="${spread > 0 ? "dilate" : "erode"}" radius="${format( ? `<feMorphology in="SourceAlpha" operator="${spread > 0 ? "dilate" : "erode"}" radius="${format(
Math.abs(spread) Math.abs(spread)
)}" result="spreadAlpha"/>` )}" result="spreadAlpha"/>`
: "" : ""
const blurInput = spread ? "spreadAlpha" : "SourceAlpha" const blurInput = spread ? "spreadAlpha" : "SourceAlpha"
const shadowResult = shadow.showShadowBehindNode === false ? "visibleShadow" : "shadow" const shadowResult = shadow?.showShadowBehindNode === false ? "visibleShadow" : "shadow"
const hideShadowBehindSource = const hideShadowBehindSource =
shadow.showShadowBehindNode === false shadow?.showShadowBehindNode === false
? `<feComposite in="shadow" in2="SourceAlpha" operator="out" result="visibleShadow"/>` ? `<feComposite in="shadow" in2="SourceAlpha" operator="out" result="visibleShadow"/>`
: "" : ""
const layerBlurMarkup = layerBlur
// Figma can store showShadowBehindNode=false. SVG's feDropShadow always ? `<feGaussianBlur in="SourceGraphic" stdDeviation="${format(blurRadius / 2)}" result="layerBlur"/>`
// 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. const sourceGraphicResult = layerBlur ? "layerBlur" : "SourceGraphic"
context.defs.push( const shadowMarkup = shadow
`<filter id="${id}" x="${format(x)}" y="${format(y)}" width="${format(width)}" height="${format( ? `${sourceAlpha}<feGaussianBlur in="${blurInput}" stdDeviation="${format(
height shadowRadius / 2
)}" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">${sourceAlpha}<feGaussianBlur in="${blurInput}" stdDeviation="${format(
radius / 2
)}" result="blurredShadow"/><feOffset in="blurredShadow" dx="${format(offsetX)}" dy="${format( )}" result="blurredShadow"/><feOffset in="blurredShadow" dx="${format(offsetX)}" dy="${format(
offsetY offsetY
)}" result="offsetShadow"/><feFlood flood-color="${colorToCss(shadow.color)}" flood-opacity="${format( )}" result="offsetShadow"/><feFlood flood-color="${colorToCss(shadow.color)}" flood-opacity="${format(
shadow.color?.a ?? 1 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>` )}" result="shadowColor"/><feComposite in="shadowColor" in2="offsetShadow" operator="in" result="shadow"/>${hideShadowBehindSource}`
: ""
const shadowMergeNode = shadow ? `<feMergeNode in="${shadowResult}"/>` : ""
let innerShadowMarkup = ""
let sourceWithInnerShadows = sourceGraphicResult
innerShadows.forEach((innerShadow, index) => {
const result = `innerShadowShape-${index}`
innerShadowMarkup += context.pngFigmaLike
? createFigmaLikeInnerShadowMarkup(innerShadow, index, sourceWithInnerShadows, result)
: createInnerShadowMarkup(innerShadow, index, sourceWithInnerShadows, result)
sourceWithInnerShadows = result
})
// Figma can combine outside-only shadows with layer blur on one node. Keep
// both effects in a single filter so the layer order matches Figma export.
context.defs.push(
`<filter id="${id}" x="${format(x)}" y="${format(y)}" width="${format(width)}" height="${format(
height
)}" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">${shadowMarkup}${layerBlurMarkup}${innerShadowMarkup}<feMerge>${shadowMergeNode}<feMergeNode in="${sourceWithInnerShadows}"/></feMerge></filter>`
) )
return id return id
} }
function createFigmaLikeInnerShadowMarkup(effect: FigEffect, index: number, shapeInput: string, result: string): string {
const radius = effect.radius ?? 0
const spread = effect.spread ?? 0
const offsetX = effect.offset?.x ?? 0
const offsetY = effect.offset?.y ?? 0
const edgeRadius = Math.max(0.5, spread + 0.5 || radius * 0.25)
const blurRadius = Math.max(0, radius * 0.2)
const hardShape = `innerHardShape-${index}`
const edgeResult = `innerEdge-${index}`
const offsetResult = `innerEdgeOffset-${index}`
const blurResult = `innerEdgeBlur-${index}`
const clippedResult = `innerEdgeClip-${index}`
const boostedResult = `innerEdgeBoost-${index}`
const colorResult = `innerEdgeColor-${index}`
const shadowResult = `innerFigmaLikeShadow-${index}`
// Figma's native PNG path treats low-opacity source fills as a hard shape
// when building inner-shadow coverage. resvg follows the SVG filter literally,
// which overfills the center; this compensated edge band keeps the center
// translucent and strengthens only the inside edge.
return `<feComponentTransfer in="SourceAlpha" result="${hardShape}"><feFuncA type="linear" slope="20"/></feComponentTransfer><feMorphology in="${hardShape}" operator="erode" radius="${format(
edgeRadius
)}" result="${edgeResult}-eroded"/><feComposite in="${hardShape}" in2="${edgeResult}-eroded" operator="out" result="${edgeResult}"/><feOffset in="${edgeResult}" dx="${format(
offsetX
)}" dy="${format(offsetY)}" result="${offsetResult}"/><feGaussianBlur in="${offsetResult}" stdDeviation="${format(
blurRadius
)}" result="${blurResult}"/><feComposite in="${blurResult}" in2="${hardShape}" operator="in" result="${clippedResult}"/><feComponentTransfer in="${clippedResult}" result="${boostedResult}"><feFuncA type="linear" slope="1.2"/></feComponentTransfer><feFlood flood-color="${colorToCss(
effect.color
)}" flood-opacity="${format(effect.color?.a ?? 1)}" result="${colorResult}"/><feComposite in="${colorResult}" in2="${boostedResult}" operator="in" result="${shadowResult}"/><feBlend mode="normal" in="${shadowResult}" in2="${shapeInput}" result="${result}"/>`
}
function createInnerShadowMarkup(effect: FigEffect, index: number, shapeInput: string, result: string): string {
const radius = effect.radius ?? 0
const spread = effect.spread ?? 0
const offsetX = effect.offset?.x ?? 0
const offsetY = effect.offset?.y ?? 0
const hardAlpha = `innerHardAlpha-${index}`
const spreadResult = `innerSpread-${index}`
const offsetResult = `innerOffset-${index}`
const blurResult = `innerBlur-${index}`
const shadowResult = `innerShadow-${index}`
const source =
spread !== 0
? `<feMorphology in="SourceAlpha" operator="${spread > 0 ? "erode" : "dilate"}" radius="${format(
Math.abs(spread)
)}" result="${spreadResult}"/>`
: ""
const sourceInput = spread !== 0 ? spreadResult : "SourceAlpha"
// Figma's own SVG export builds inner shadows with hardAlpha and arithmetic
// compositing; using a simpler mask changes the edge strength noticeably.
return `<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="${hardAlpha}"/>${source}<feOffset in="${sourceInput}" dx="${format(
offsetX
)}" dy="${format(offsetY)}" result="${offsetResult}"/><feGaussianBlur in="${offsetResult}" stdDeviation="${format(
radius / 2
)}" result="${blurResult}"/><feComposite in="${blurResult}" in2="${hardAlpha}" operator="arithmetic" k2="-1" k3="1" result="${shadowResult}"/><feColorMatrix in="${shadowResult}" type="matrix" values="${colorMatrixValues(
effect.color
)}" result="${shadowResult}-color"/><feBlend mode="normal" in="${shadowResult}-color" in2="${shapeInput}" result="${result}"/>`
}
function createNodeEffectFilter(context: RenderContext, node: FigNode, matrix: SvgMatrix): string | null { function createNodeEffectFilter(context: RenderContext, node: FigNode, matrix: SvgMatrix): string | null {
const localBounds = getNodeLocalBounds(context, node) const localBounds = getNodeLocalBounds(context, node)
if (!localBounds) return null if (!localBounds) return null
const effects = shouldRenderFigmaLikeEllipseInnerShadowOverlay(context, node)
? node.effects?.filter((effect) => effect.type !== "INNER_SHADOW")
: node.effects
// Effects belong to the Figma layer itself. Frames/groups can carry shadows // 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 // even when their own fill paints are empty, so the filter must wrap the
// rendered layer content instead of only individual geometry paths. // rendered layer content instead of only individual geometry paths.
const filterId = createFilter(context, node.effects, localBounds) const filterId = createFilter(context, effects, transformBounds(matrix, localBounds))
if (!filterId) return null if (!filterId) return null
includeBounds(context, transformBounds(matrix, expandBoundsForEffects(localBounds, node.effects))) const expandedBounds = transformBounds(matrix, expandBoundsForEffects(localBounds, effects))
includeBounds(context, expandedBounds)
includeEffectBounds(context, expandedBounds)
return filterId return filterId
} }
function shouldRenderFigmaLikeEllipseInnerShadowOverlay(context: RenderContext, node: FigNode): boolean {
return Boolean(
context.pngFigmaLike &&
node.type === "ELLIPSE" &&
node.size &&
isFullEllipse(node.arcData) &&
node.effects?.some((effect) => effect.visible !== false && effect.type === "INNER_SHADOW")
)
}
function collectFigmaLikeEllipseInnerShadowHint(context: RenderContext, node: FigNode, matrix: SvgMatrix): string {
if (!shouldRenderFigmaLikeEllipseInnerShadowOverlay(context, node) || !node.size) return ""
const innerShadow = node.effects?.find((effect) => effect.visible !== false && effect.type === "INNER_SHADOW")
if (!innerShadow) return ""
const rx = node.size.x / 2
const ry = node.size.y / 2
const cx = rx
const cy = ry
const minRadius = Math.max(1, Math.min(rx, ry))
const radius = innerShadow.radius ?? 0
const spread = innerShadow.spread ?? 0
const color = innerShadow.color ?? { r: 0, g: 0, b: 0, a: 1 }
// Figma's native PNG renderer builds inner-shadow coverage from the vector
// ellipse itself, not from the low-opacity SVG SourceAlpha filter input.
// Record a raster hint so export-node can apply the edge falloff directly to
// pixels after SVG rasterization, bypassing backend-specific filter behavior.
context.rasterHints.push({
type: "ellipse-inner-shadow",
matrix,
cx,
cy,
rx,
ry,
color,
opacity: color.a,
spread: Math.max(0, Math.min(minRadius - 0.5, spread)),
blurSigma: Math.max(0.5, Math.min(minRadius, radius * 0.42))
})
return ""
}
function getRootExportBounds(target: FigNode, renderedBounds: Bounds | null, effectBounds: Bounds | null): Bounds {
if (target.size) {
const targetBounds = {
minX: 0,
minY: 0,
maxX: target.size.x,
maxY: target.size.y
}
// Native exports keep the node's nominal box, then extend the bitmap when
// visible filters such as foreground blur reach outside that box.
return effectBounds ? unionBounds(targetBounds, effectBounds) : targetBounds
}
return renderedBounds ?? { minX: 0, minY: 0, maxX: 0, maxY: 0 }
}
function getFigmaPixelSize(size: number, scale: number): number {
const raw = size * scale
const rounded = Math.round(raw)
// Figma files often contain tiny float noise around integer layer sizes. The
// native export uses the intended integer in those cases, but still expands
// genuinely fractional selections so artwork is not clipped.
if (Math.abs(raw - rounded) < 0.01) return Math.max(1, rounded)
return Math.max(1, Math.ceil(raw))
}
function getNodeLocalBounds(context: RenderContext, node: FigNode): Bounds | null { function getNodeLocalBounds(context: RenderContext, node: FigNode): Bounds | null {
const geometryBounds = getGeometryLocalBounds(context, node) const geometryBounds = getGeometryLocalBounds(context, node)
if (geometryBounds) return geometryBounds if (geometryBounds) return geometryBounds
@ -394,10 +800,17 @@ function getGeometryLocalBounds(context: RenderContext, node: FigNode): Bounds |
function getSortedChildren(context: RenderContext, node: FigNode): FigNode[] { function getSortedChildren(context: RenderContext, node: FigNode): FigNode[] {
return [...(context.childrenByParent.get(keyForGuid(node.guid)) ?? [])].sort((a, b) => return [...(context.childrenByParent.get(keyForGuid(node.guid)) ?? [])].sort((a, b) =>
(a.parentIndex?.position ?? "").localeCompare(b.parentIndex?.position ?? "") comparePosition(a.parentIndex?.position ?? "", b.parentIndex?.position ?? "")
) )
} }
function comparePosition(left: string, right: string): number {
// parentIndex.position is a fractional-index string; locale-aware sorting
// reorders punctuation and changes Figma's z-order.
if (left === right) return 0
return left < right ? -1 : 1
}
function nextId(context: RenderContext, prefix: string): string { function nextId(context: RenderContext, prefix: string): string {
context.idSeed += 1 context.idSeed += 1
return `${prefix}-${context.idSeed}` return `${prefix}-${context.idSeed}`
@ -462,10 +875,30 @@ function transformBounds(matrix: SvgMatrix, bounds: Bounds): Bounds {
return points.reduce((next, point) => includePoint(next, point.x, point.y), createEmptyBounds()) return points.reduce((next, point) => includePoint(next, point.x, point.y), createEmptyBounds())
} }
function expandBounds(bounds: Bounds, amount: number): Bounds {
return {
minX: bounds.minX - amount,
minY: bounds.minY - amount,
maxX: bounds.maxX + amount,
maxY: bounds.maxY + amount
}
}
function expandBoundsForEffects(bounds: Bounds, effects: FigEffect[] | undefined): Bounds { function expandBoundsForEffects(bounds: Bounds, effects: FigEffect[] | undefined): Bounds {
const next = { ...bounds } const next = { ...bounds }
for (const effect of effects ?? []) { for (const effect of effects ?? []) {
if (effect.visible === false || effect.type !== "DROP_SHADOW") continue if (effect.visible === false) continue
if (effect.type === "FOREGROUND_BLUR" || effect.type === "LAYER_BLUR") {
const radius = effect.radius ?? 0
next.minX = Math.min(next.minX, bounds.minX - radius)
next.maxX = Math.max(next.maxX, bounds.maxX + radius)
next.minY = Math.min(next.minY, bounds.minY - radius)
next.maxY = Math.max(next.maxY, bounds.maxY + radius)
continue
}
if (effect.type !== "DROP_SHADOW") continue
const radius = effect.radius ?? 0 const radius = effect.radius ?? 0
const spread = effect.spread ?? 0 const spread = effect.spread ?? 0
@ -500,6 +933,10 @@ function includeBounds(context: RenderContext, bounds: Bounds) {
: { ...bounds } : { ...bounds }
} }
function includeEffectBounds(context: RenderContext, bounds: Bounds) {
context.effectBounds = context.effectBounds ? unionBounds(context.effectBounds, bounds) : { ...bounds }
}
function unionBounds(left: Bounds, right: Bounds): Bounds { function unionBounds(left: Bounds, right: Bounds): Bounds {
return { return {
minX: Math.min(left.minX, right.minX), minX: Math.min(left.minX, right.minX),
@ -523,6 +960,48 @@ function colorToCss(color?: FigColor): string {
return `rgb(${toByte(color.r)} ${toByte(color.g)} ${toByte(color.b)})` return `rgb(${toByte(color.r)} ${toByte(color.g)} ${toByte(color.b)})`
} }
function colorMatrixValues(color?: FigColor): string {
const next = color ?? { r: 0, g: 0, b: 0, a: 1 }
return [
0,
0,
0,
0,
next.r,
0,
0,
0,
0,
next.g,
0,
0,
0,
0,
next.b,
0,
0,
0,
next.a,
0
]
.map(format)
.join(" ")
}
function paintOpacityAttribute(kind: "fill" | "stroke", paint: FigPaint): string {
const opacity = (paint.opacity ?? 1) * (paint.type === "SOLID" ? paint.color?.a ?? 1 : 1)
return opacity !== 1 ? ` ${kind}-opacity="${format(opacity)}"` : ""
}
function strokeDashArrayAttribute(node: FigNode): string {
const dashPattern = node.dashPattern?.filter((value) => value > 0)
if (!dashPattern?.length) return ""
// Figma preserves dashed strokes as stroke metadata. Emitting dasharray keeps
// the semantic dashed stroke instead of rasterizing it from baked contours.
return ` stroke-dasharray="${dashPattern.map(format).join(" ")}"`
}
function toByte(value: number): number { function toByte(value: number): number {
return Math.round(Math.max(0, Math.min(1, value)) * 255) return Math.round(Math.max(0, Math.min(1, value)) * 255)
} }

View File

@ -61,6 +61,7 @@ export type FigNode = {
transform?: FigmaMatrix transform?: FigmaMatrix
strokeWeight?: number strokeWeight?: number
strokeAlign?: "CENTER" | "INSIDE" | "OUTSIDE" strokeAlign?: "CENTER" | "INSIDE" | "OUTSIDE"
dashPattern?: number[]
arcData?: FigArcData arcData?: FigArcData
fillPaints?: FigPaint[] fillPaints?: FigPaint[]
strokePaints?: FigPaint[] strokePaints?: FigPaint[]