From 21198de5c298ac16904c625a449ce9eb707207e5 Mon Sep 17 00:00:00 2001 From: sunlei Date: Sun, 24 May 2026 14:37:56 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=9C=AC=E5=9C=B0=20Figma=20?= =?UTF-8?q?PNG=20=E5=AF=BC=E5=87=BA=E6=8B=9F=E7=9C=9F=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 18 +- src/mcp/index.ts | 33 +- src/services/design-context.ts | 10 +- src/services/export-node.ts | 325 ++++++++++++++++++- src/services/fig-node-svg.ts | 577 ++++++++++++++++++++++++++++++--- src/services/fig-types.ts | 1 + 6 files changed, 892 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 10845fc..de60f24 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ - `get_design_tokens`:从全文件或指定节点子树中推导颜色、渐变、阴影、描边 token。 - `inspect_fig_file`:读取本地 `.fig`,返回节点数量、类型统计和节点概览。 - `get_fig_node`:按节点名、`1234:5678`、`1234-5678`、`node-id=1234-5678` 或完整 Figma 链接查找节点,并返回简化上下文。 -- `export_fig_node`:把指定节点导出为 SVG 或 PNG,PNG 支持倍率。 +- `export_fig_node`:把指定节点导出为 SVG 或 PNG,PNG 支持倍率,默认走本地 `figma-like` 渲染。它会在本 MCP 生成的 SVG 上对部分滤镜/内阴影做补偿后再用 `@resvg/resvg-js` 栅格化,目标是接近 Figma 在线端 PNG,但不等同于 Figma 原生 PNG 导出。 ## 示例参数 @@ -97,10 +97,24 @@ "nodeQuery": "1234-5678", "outputPath": "C:\\Users\\you\\Exports\\figma-local-context\\sample-node.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 diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 73531c6..c125a5b 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -13,7 +13,8 @@ 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." + 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({ @@ -37,9 +38,13 @@ const exportNodeParams = z.object({ .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。") + 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。"), + background: z.string().optional().describe("可选背景色,例如 #ffffff。"), + pngRenderer: z + .enum(["figma-like", "local-preview"]) + .default("figma-like") + .describe("PNG 渲染器:figma-like 会增强滤镜/内阴影以接近 Figma PNG;local-preview 是普通 SVG 栅格化。") }) const listNodesParams = z.object({ @@ -78,9 +83,13 @@ const exportAssetsParams = z.object({ .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。") + 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。"), + background: z.string().optional().describe("可选背景色,例如 #ffffff。"), + pngRenderer: z + .enum(["figma-like", "local-preview"]) + .default("figma-like") + .describe("PNG 渲染器:figma-like 会增强滤镜/内阴影以接近 Figma PNG;local-preview 是普通 SVG 栅格化。") }) const designTokensParams = z.object({ @@ -161,7 +170,7 @@ export function createServer(): McpServer { "export_assets", { title: "Export design assets", - description: "批量导出本地 .fig/.fig.json 中的多个节点为 SVG 或 PNG。", + description: "批量导出本地 .fig/.fig.json 中的多个节点为 SVG 或本地 figma-like PNG。", inputSchema: exportAssetsParams, annotations: { readOnlyHint: false, openWorldHint: true } }, @@ -173,7 +182,8 @@ export function createServer(): McpServer { outputDir: params.outputDir, format: params.format, scale: params.scale, - background: params.background + background: params.background, + pngRenderer: params.pngRenderer }) ) ) @@ -215,7 +225,7 @@ export function createServer(): McpServer { "export_fig_node", { title: "Export local Figma node", - description: "把本地 .fig/.fig.json 中的指定节点导出为 SVG 或 PNG。", + description: "把本地 .fig/.fig.json 中的指定节点导出为 SVG 或本地 figma-like PNG。", inputSchema: exportNodeParams, annotations: { readOnlyHint: false, openWorldHint: true } }, @@ -227,7 +237,8 @@ export function createServer(): McpServer { outputPath: params.outputPath, format: params.format, scale: params.scale, - background: params.background + background: params.background, + pngRenderer: params.pngRenderer }) ) ) diff --git a/src/services/design-context.ts b/src/services/design-context.ts index 5351209..6f28b04 100644 --- a/src/services/design-context.ts +++ b/src/services/design-context.ts @@ -1,6 +1,6 @@ import path from "node:path" 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 { getRootNode, loadFigFile, serializeNode, summarizeNode } from "./fig-file.js" import { keyForGuid, sanitizeFilePart } from "../utils/node-id.js" @@ -33,6 +33,7 @@ export type ExportAssetsOptions = { format: "svg" | "png" scale: number background?: string + pngRenderer?: PngRenderer } export function listFigNodes(filePath: string, options: NodeListOptions) { @@ -102,7 +103,8 @@ export function exportAssets(options: ExportAssetsOptions) { outputPath: path.join(options.outputDir, fileName), format: options.format, scale: options.scale, - background: options.background + background: options.background, + pngRenderer: options.pngRenderer }) ) } @@ -159,7 +161,9 @@ function buildCodeHints(node: FigNode, childrenByParent: Map, }, 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.` + ? `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 } diff --git a/src/services/export-node.ts b/src/services/export-node.ts index 078b9ef..28e8b35 100644 --- a/src/services/export-node.ts +++ b/src/services/export-node.ts @@ -1,10 +1,15 @@ import fs from "node:fs" import path from "node:path" +import zlib from "node:zlib" import { Resvg } from "@resvg/resvg-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" +export type PngRenderer = "figma-like" | "local-preview" + +const MASK_SUPERSAMPLE = 8 + export type ExportNodeOptions = { filePath: string nodeQuery: string @@ -12,6 +17,7 @@ export type ExportNodeOptions = { format: "svg" | "png" scale: number background?: string + pngRenderer?: PngRenderer } export type ExportNodeResult = { @@ -21,6 +27,31 @@ export type ExportNodeResult = { scale: number width: 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: { id: string name?: string @@ -30,20 +61,46 @@ export type ExportNodeResult = { export function exportFigNode(options: ExportNodeOptions): ExportNodeResult { const figJson = loadFigFile(options.filePath) + const pngRenderer = options.pngRenderer ?? "figma-like" + const useFigmaLikePng = options.format === "png" && pngRenderer === "figma-like" const rendered = renderNodeToSvg(figJson, { nodeQuery: options.nodeQuery, scale: options.scale, - background: options.background + background: options.background, + pngFigmaLike: useFigmaLikePng }) 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 }) + if (options.format === "svg") { fs.writeFileSync(outputPath, rendered.svg) } 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 { filePath: path.resolve(options.filePath), outputPath, @@ -51,10 +108,36 @@ export function exportFigNode(options: ExportNodeOptions): ExportNodeResult { scale: options.scale, width: rendered.width, height: rendered.height, - node: { - id: keyForGuid(rendered.node.guid), - name: rendered.node.name, - type: rendered.node.type + renderer: options.format === "svg" ? "local-svg" : useFigmaLikePng ? "local-figma-like-resvg" : "local-svg-resvg", + exportCapabilities: getExportCapabilities(), + warnings, + 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` : "" 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) +} diff --git a/src/services/fig-node-svg.ts b/src/services/fig-node-svg.ts index fd2d447..de933bf 100644 --- a/src/services/fig-node-svg.ts +++ b/src/services/fig-node-svg.ts @@ -22,8 +22,24 @@ type RenderContext = { figJson: FigJson childrenByParent: Map defs: string[] + rasterHints: FigmaLikeRasterHint[] bounds: Bounds | null + effectBounds: Bounds | null 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 = { @@ -32,6 +48,7 @@ export type RenderOptions = { nodeQuery?: string scale?: number background?: string + pngFigmaLike?: boolean } export type RenderedSvg = { @@ -39,6 +56,7 @@ export type RenderedSvg = { width: number height: number viewBox: Bounds + rasterHints: FigmaLikeRasterHint[] node: FigNode } @@ -55,29 +73,48 @@ export function renderNodeToSvg(figJson: FigJson, options: RenderOptions): Rende figJson, childrenByParent, defs: [], + rasterHints: [], bounds: null, - idSeed: 0 + effectBounds: null, + idSeed: 0, + pngFigmaLike: options.pngFigmaLike ?? false } 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 height = bounds.maxY - bounds.minY 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 svg = [ - ``, + ``, context.defs.length ? `${context.defs.join("")}` : "", background, body, "" ].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 { @@ -120,29 +157,200 @@ function renderNodeSubtree( const localMatrix = isRoot ? IDENTITY : toSvgMatrix(node.transform) 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 = [ ...renderGeometry(context, node, node.fillGeometry, node.fillPaints, matrix), + collectFigmaLikeEllipseInnerShadowHint(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("") 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 `${nodeContent}` + return `${nodeContent}` } function renderStrokeGeometry(context: RenderContext, node: FigNode, matrix: SvgMatrix): string[] { const outsideEllipseStroke = renderOutsideEllipseStroke(context, node, matrix) if (outsideEllipseStroke) return outsideEllipseStroke + const pathStroke = renderPathStroke(context, node, matrix) + if (pathStroke) return pathStroke + 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 `` + }) + }) +} + +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 { const strokeWeight = node.strokeWeight ?? 0 if ( @@ -167,17 +375,20 @@ function renderOutsideEllipseStroke(context: RenderContext, node: FigNode, matri 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 stroke = paintToSvgFill(context, node, bounds, paint, IDENTITY) + const opacity = paintOpacityAttribute("stroke", paint) const rx = node.size!.x / 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 // outline. Using it directly paints inward and erases the ring gap, so // complete ellipses are emitted as actual outside strokes. return `` + )}" 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 transformedBounds = transformBounds(matrix, parsed.bounds) includeBounds(context, transformedBounds) + const fillRule = geometry.windingRule === "ODD" ? ` fill-rule="evenodd"` : "" 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)}"` : "" + const fill = paintToSvgFill(context, node, parsed.bounds, paint, matrix) + const opacity = paintOpacityAttribute("fill", paint) - return `` + return `` }) }) } @@ -261,14 +473,45 @@ function parsePathBlob(figJson: FigJson, blobIndex: number): ParsedPath { 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") { return colorToCss(paint.color) } if (paint.type === "GRADIENT_LINEAR") { const id = nextId(context, "gradient") - const gradient = getLinearGradientLine(node, pathBounds, paint) + const gradient = getLinearGradientLine(node, pathBounds, paint, matrix) const stops = paint.stops ?.map( (stop) => @@ -289,7 +532,7 @@ function paintToSvgFill(context: RenderContext, node: FigNode, pathBounds: Bound 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 height = node.size?.y || pathBounds.maxY - pathBounds.minY 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. const start = applyToPoint(inverse, 0, 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 { - x1: originX + start.x * width, - y1: originY + start.y * height, - x2: originX + end.x * width, - y2: originY + end.y * height + x1: transformedStart.x, + y1: transformedStart.y, + x2: transformedEnd.x, + y2: transformedEnd.y } } 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 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 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 + const shadowRadius = shadow?.radius ?? 0 + const blurRadius = layerBlur?.radius ?? 0 + const spread = shadow?.spread ?? 0 + const offsetX = shadow?.offset?.x ?? 0 + const offsetY = shadow?.offset?.y ?? 0 + const filterPadding = Math.max(shadowRadius + Math.abs(spread), blurRadius) + const x = bounds.minX + Math.min(0, offsetX) - filterPadding + const y = bounds.minY + Math.min(0, offsetY) - filterPadding + 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 ? `` : "" const blurInput = spread ? "spreadAlpha" : "SourceAlpha" - const shadowResult = shadow.showShadowBehindNode === false ? "visibleShadow" : "shadow" + const shadowResult = shadow?.showShadowBehindNode === false ? "visibleShadow" : "shadow" const hideShadowBehindSource = - shadow.showShadowBehindNode === false + shadow?.showShadowBehindNode === false ? `` : "" + const layerBlurMarkup = layerBlur + ? `` + : "" + const sourceGraphicResult = layerBlur ? "layerBlur" : "SourceGraphic" + const shadowMarkup = shadow + ? `${sourceAlpha}${hideShadowBehindSource}` + : "" + const shadowMergeNode = shadow ? `` : "" + 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 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. + // 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( `${sourceAlpha}${hideShadowBehindSource}` + )}" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">${shadowMarkup}${layerBlurMarkup}${innerShadowMarkup}${shadowMergeNode}` ) 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 `` +} + +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 + ? `` + : "" + 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 `${source}` +} + function createNodeEffectFilter(context: RenderContext, node: FigNode, matrix: SvgMatrix): string | null { const localBounds = getNodeLocalBounds(context, node) 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 // 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) + const filterId = createFilter(context, effects, transformBounds(matrix, localBounds)) 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 } +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 { const geometryBounds = getGeometryLocalBounds(context, node) if (geometryBounds) return geometryBounds @@ -394,10 +800,17 @@ function getGeometryLocalBounds(context: RenderContext, node: FigNode): Bounds | function getSortedChildren(context: RenderContext, node: FigNode): FigNode[] { 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 { context.idSeed += 1 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()) } +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 { const next = { ...bounds } 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 spread = effect.spread ?? 0 @@ -500,6 +933,10 @@ function includeBounds(context: RenderContext, 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 { return { 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)})` } +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 { return Math.round(Math.max(0, Math.min(1, value)) * 255) } diff --git a/src/services/fig-types.ts b/src/services/fig-types.ts index 175fadf..b9e4523 100644 --- a/src/services/fig-types.ts +++ b/src/services/fig-types.ts @@ -61,6 +61,7 @@ export type FigNode = { transform?: FigmaMatrix strokeWeight?: number strokeAlign?: "CENTER" | "INSIDE" | "OUTSIDE" + dashPattern?: number[] arcData?: FigArcData fillPaints?: FigPaint[] strokePaints?: FigPaint[]