feat: support local fig image assets in svg export

This commit is contained in:
sunlei 2026-05-24 19:22:21 +08:00
parent 21198de5c2
commit 1e3d3ee6a1
5 changed files with 449 additions and 7 deletions

View File

@ -108,6 +108,8 @@
- `local-figma-like-resvg`:默认 PNG 管线。先生成带 Figma-like 滤镜补偿的本地 SVG再用 `@resvg/resvg-js` 转 PNG。 - `local-figma-like-resvg`:默认 PNG 管线。先生成带 Figma-like 滤镜补偿的本地 SVG再用 `@resvg/resvg-js` 转 PNG。
- `local-svg-resvg`:普通预览 PNG 管线。先生成本地 SVG再用 `@resvg/resvg-js` 转 PNG。Figma 在线端 PNG 使用自身渲染管线,不能假定它是在线 SVG 再转 PNG。 - `local-svg-resvg`:普通预览 PNG 管线。先生成本地 SVG再用 `@resvg/resvg-js` 转 PNG。Figma 在线端 PNG 使用自身渲染管线,不能假定它是在线 SVG 再转 PNG。
当前本地 SVG/PNG 管线支持 `.fig` 外层 zip 中的 `images/<hash>` 图片资源,会把 `IMAGE` 填充按真实图片、paint transform 和矢量路径裁切导出;如果本地文件缺少对应图片资源,会明确报出缺失的 IMAGE hash。线性/径向渐变、虚线描边和部分滤镜也会转换为 SVG 近似表达。
导出结果还会包含 `exportCapabilities` 导出结果还会包含 `exportCapabilities`
- `localSvg.supported: true`:支持从本地 `.fig` 解码生成 SVG。 - `localSvg.supported: true`:支持从本地 `.fig` 解码生成 SVG。

View File

@ -3,6 +3,7 @@ import path from "node:path"
import zlib from "node:zlib" 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 { loadFigImageAssets } from "./fig-images.js"
import { renderNodeToSvg, type FigmaLikeRasterHint } 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"
@ -61,13 +62,15 @@ 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 imageAssets = loadFigImageAssets(options.filePath)
const pngRenderer = options.pngRenderer ?? "figma-like" const pngRenderer = options.pngRenderer ?? "figma-like"
const useFigmaLikePng = options.format === "png" && 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 pngFigmaLike: useFigmaLikePng,
imageAssets
}) })
const outputPath = path.resolve(options.outputPath ?? defaultOutputPath(options)) const outputPath = path.resolve(options.outputPath ?? defaultOutputPath(options))
const node = { const node = {

View File

@ -0,0 +1,58 @@
import fs from "node:fs"
import path from "node:path"
import UzipModule from "uzip"
const UZIP = (UzipModule as any).default ?? UzipModule
export type FigImageAssets = Map<string, string>
export function loadFigImageAssets(filePath: string): FigImageAssets {
const absolutePath = path.resolve(filePath)
if (absolutePath.toLowerCase().endsWith(".json")) return new Map()
const fileBytes = fs.readFileSync(absolutePath)
if (!isZipFile(fileBytes)) return new Map()
const unzipped = UZIP.parse(toArrayBuffer(fileBytes))
const assets: FigImageAssets = new Map()
for (const [entryName, entryBytes] of Object.entries(unzipped) as Array<[string, Uint8Array]>) {
if (!entryName.startsWith("images/") || entryName.endsWith("/")) continue
const hash = path.posix.basename(entryName).toLowerCase()
const mimeType = detectImageMimeType(entryBytes)
if (!mimeType) continue
assets.set(hash, `data:${mimeType};base64,${Buffer.from(entryBytes).toString("base64")}`)
}
return assets
}
function detectImageMimeType(bytes: Uint8Array): string | null {
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return "image/png"
if (bytes[0] === 0xff && bytes[1] === 0xd8) return "image/jpeg"
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return "image/gif"
if (
bytes[0] === 0x52 &&
bytes[1] === 0x49 &&
bytes[2] === 0x46 &&
bytes[3] === 0x46 &&
bytes[8] === 0x57 &&
bytes[9] === 0x45 &&
bytes[10] === 0x42 &&
bytes[11] === 0x50
) {
return "image/webp"
}
return null
}
function isZipFile(bytes: Uint8Array): boolean {
return bytes[0] === 0x50 && bytes[1] === 0x4b
}
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
}

View File

@ -7,8 +7,10 @@ import type {
FigJson, FigJson,
FigNode, FigNode,
FigPaint, FigPaint,
FigTextGlyph,
FigmaMatrix FigmaMatrix
} from "./fig-types.js" } from "./fig-types.js"
import type { FigImageAssets } from "./fig-images.js"
import { keyForGuid, normalizeNodeId } from "../utils/node-id.js" import { keyForGuid, normalizeNodeId } from "../utils/node-id.js"
type SvgMatrix = [number, number, number, number, number, number] type SvgMatrix = [number, number, number, number, number, number]
@ -23,6 +25,7 @@ type RenderContext = {
childrenByParent: Map<string, FigNode[]> childrenByParent: Map<string, FigNode[]>
defs: string[] defs: string[]
rasterHints: FigmaLikeRasterHint[] rasterHints: FigmaLikeRasterHint[]
imageAssets: FigImageAssets
bounds: Bounds | null bounds: Bounds | null
effectBounds: Bounds | null effectBounds: Bounds | null
idSeed: number idSeed: number
@ -49,6 +52,7 @@ export type RenderOptions = {
scale?: number scale?: number
background?: string background?: string
pngFigmaLike?: boolean pngFigmaLike?: boolean
imageAssets?: FigImageAssets
} }
export type RenderedSvg = { export type RenderedSvg = {
@ -63,6 +67,7 @@ export type RenderedSvg = {
const IDENTITY: SvgMatrix = [1, 0, 0, 1, 0, 0] const IDENTITY: SvgMatrix = [1, 0, 0, 1, 0, 0]
const COMMAND_MOVE = 1 const COMMAND_MOVE = 1
const COMMAND_LINE = 2 const COMMAND_LINE = 2
const COMMAND_QUADRATIC = 3
const COMMAND_CUBIC = 4 const COMMAND_CUBIC = 4
const COMMAND_CLOSE = 0 const COMMAND_CLOSE = 0
@ -74,6 +79,7 @@ export function renderNodeToSvg(figJson: FigJson, options: RenderOptions): Rende
childrenByParent, childrenByParent,
defs: [], defs: [],
rasterHints: [], rasterHints: [],
imageAssets: options.imageAssets ?? new Map(),
bounds: null, bounds: null,
effectBounds: null, effectBounds: null,
idSeed: 0, idSeed: 0,
@ -163,9 +169,10 @@ function renderNodeSubtree(
node.type !== "BOOLEAN_OPERATION" || !(node.fillGeometry?.length || node.strokeGeometry?.length) node.type !== "BOOLEAN_OPERATION" || !(node.fillGeometry?.length || node.strokeGeometry?.length)
const nodeContent = [ const nodeContent = [
...renderGeometry(context, node, node.fillGeometry, node.fillPaints, matrix), ...renderGeometry(context, node, node.fillGeometry, node.fillPaints, matrix),
renderTextNode(context, node, matrix),
collectFigmaLikeEllipseInnerShadowHint(context, node, matrix), collectFigmaLikeEllipseInnerShadowHint(context, node, matrix),
...renderStrokeGeometry(context, node, matrix), ...renderStrokeGeometry(context, node, matrix),
...(shouldRenderChildren ? getSortedChildren(context, node).map((child) => renderNodeSubtree(context, child, matrix)) : []) ...(shouldRenderChildren ? renderChildNodes(context, node, matrix) : [])
].join("") ].join("")
if (!nodeContent) return "" if (!nodeContent) return ""
@ -177,12 +184,170 @@ function renderNodeSubtree(
return `<g${opacity}${filter}>${nodeContent}</g>` return `<g${opacity}${filter}>${nodeContent}</g>`
} }
function renderChildNodes(context: RenderContext, node: FigNode, matrix: SvgMatrix): string[] {
const output: string[] = []
let activeClipId: string | null = null
for (const child of getSortedChildren(context, node)) {
if (child.visible === false) continue
if (child.mask) {
activeClipId = createMaskClipPath(context, child, matrix)
continue
}
const rendered = renderNodeSubtree(context, child, matrix)
if (!rendered) continue
output.push(activeClipId ? `<g clip-path="url(#${activeClipId})">${rendered}</g>` : rendered)
}
return output
}
function createMaskClipPath(context: RenderContext, node: FigNode, parentMatrix: SvgMatrix): string | null {
const content = renderClipPathNode(context, node, parentMatrix)
if (!content) return null
const id = nextId(context, "clip")
context.defs.push(`<clipPath id="${id}" clipPathUnits="userSpaceOnUse">${content}</clipPath>`)
return id
}
function renderClipPathNode(context: RenderContext, node: FigNode, parentMatrix: SvgMatrix): string {
if (node.visible === false) return ""
const matrix = multiply(parentMatrix, toSvgMatrix(node.transform))
return [
...renderGeometryClipPaths(context, node.fillGeometry, matrix),
...renderGeometryClipPaths(context, node.strokeGeometry, matrix),
...renderTextClipPaths(context, node, matrix),
...getSortedChildren(context, node).map((child) => renderClipPathNode(context, child, matrix))
].join("")
}
function renderGeometryClipPaths(
context: RenderContext,
geometries: FigGeometry[] | undefined,
matrix: SvgMatrix
): string[] {
if (!geometries?.length) return []
return geometries.flatMap((geometry) => {
const parsed = tryParsePathBlob(context.figJson, geometry.commandsBlob)
if (!parsed || !isFiniteBounds(parsed.bounds) || !parsed.d) return []
const clipRule = geometry.windingRule === "ODD" ? ` clip-rule="evenodd"` : ""
return `<path d="${transformPathData(parsed.d, matrix)}"${clipRule}/>`
})
}
function renderTextClipPaths(context: RenderContext, node: FigNode, matrix: SvgMatrix): string[] {
if (node.type !== "TEXT") return []
return (
node.derivedTextData?.glyphs?.flatMap((glyph) => {
const parsed = parsePathBlob(context.figJson, glyph.commandsBlob)
if (!isFiniteBounds(parsed.bounds) || !parsed.d) return []
return `<path d="${transformPathData(parsed.d, getGlyphMatrix(glyph, matrix))}"/>`
}) ?? []
)
}
function renderTextNode(context: RenderContext, node: FigNode, matrix: SvgMatrix): string {
if (node.type !== "TEXT") return ""
const glyphs = node.derivedTextData?.glyphs
const textBounds = getTextLocalBounds(node)
if (!glyphs?.length || !textBounds) return ""
const groups = new Map<string, { paints: FigPaint[]; paths: string[] }>()
includeBounds(context, transformBounds(matrix, textBounds))
for (const glyph of glyphs) {
const parsed = parsePathBlob(context.figJson, glyph.commandsBlob)
if (!isFiniteBounds(parsed.bounds) || !parsed.d) continue
const paints = getTextGlyphPaints(node, glyph)
if (!paints.length) continue
const key = JSON.stringify(paints)
const group = groups.get(key) ?? { paints, paths: [] }
group.paths.push(transformPathData(parsed.d, getGlyphMatrix(glyph, matrix)))
groups.set(key, group)
}
return [...groups.values()]
.flatMap((group) => renderTextPaintGroup(context, node, textBounds, group.paints, matrix, group.paths))
.join("")
}
function renderTextPaintGroup(
context: RenderContext,
node: FigNode,
textBounds: Bounds,
paints: FigPaint[],
matrix: SvgMatrix,
paths: string[]
): string[] {
if (!paths.length) return []
const d = paths.join(" ")
return paints
.filter((paint) => paint.visible !== false)
.map((paint) => {
if (paint.type === "IMAGE") return ""
const fill = paintToSvgFill(context, node, textBounds, paint, matrix)
const opacity = paintOpacityAttribute("fill", paint)
return `<path d="${d}" fill="${fill}"${opacity}/>`
})
}
function getTextGlyphPaints(node: FigNode, glyph: FigTextGlyph): FigPaint[] {
const characterIndex = glyph.firstCharacter ?? 0
const styleId = node.textData?.characterStyleIDs?.[characterIndex]
const override = node.textData?.styleOverrideTable?.find((style) => style.styleID === styleId)
return override?.fillPaints ?? node.fillPaints ?? []
}
function getTextLocalBounds(node: FigNode): Bounds | null {
const size = node.derivedTextData?.layoutSize ?? node.size
if (!size || size.x <= 0 || size.y <= 0) return null
return {
minX: 0,
minY: 0,
maxX: size.x,
maxY: size.y
}
}
function getGlyphMatrix(glyph: FigTextGlyph, textMatrix: SvgMatrix): SvgMatrix {
const position = glyph.position ?? { x: 0, y: 0 }
const fontSize = glyph.fontSize ?? 1
const rotation = glyph.rotation ?? 0
// Glyph blobs are normalized font outlines with positive Y going upward from
// the baseline. SVG local space is Y-down, so text rendering needs a vertical
// flip around Figma's stored glyph baseline.
const scale: SvgMatrix = [fontSize, 0, 0, -fontSize, 0, 0]
const glyphLocal =
rotation === 0
? ([fontSize, 0, 0, -fontSize, position.x, position.y] as SvgMatrix)
: multiply([Math.cos(rotation), Math.sin(rotation), -Math.sin(rotation), Math.cos(rotation), position.x, position.y], scale)
return multiply(textMatrix, glyphLocal)
}
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) const pathStroke = renderPathStroke(context, node, matrix)
if (pathStroke) return pathStroke if (pathStroke?.length) return pathStroke
return renderGeometry(context, node, node.strokeGeometry, node.strokePaints, matrix) return renderGeometry(context, node, node.strokeGeometry, node.strokePaints, matrix)
} }
@ -196,7 +361,9 @@ function renderPathStroke(context: RenderContext, node: FigNode, matrix: SvgMatr
if (hasGradientStroke && !shouldRenderGradientStrokeAsPath(node)) return null if (hasGradientStroke && !shouldRenderGradientStrokeAsPath(node)) return null
return node.fillGeometry.flatMap((geometry) => { return node.fillGeometry.flatMap((geometry) => {
const parsed = parsePathBlob(context.figJson, geometry.commandsBlob) const parsed = tryParsePathBlob(context.figJson, geometry.commandsBlob)
if (!parsed || !isFiniteBounds(parsed.bounds) || !parsed.d) return []
const expandedBounds = expandBounds(parsed.bounds, strokeWeight / 2) const expandedBounds = expandBounds(parsed.bounds, strokeWeight / 2)
includeBounds(context, transformBounds(matrix, expandedBounds)) includeBounds(context, transformBounds(matrix, expandedBounds))
const strokeMatrix = multiply(matrix, getStrokeAlignmentMatrix(parsed.bounds, strokeWeight, node.strokeAlign)) const strokeMatrix = multiply(matrix, getStrokeAlignmentMatrix(parsed.bounds, strokeWeight, node.strokeAlign))
@ -217,6 +384,13 @@ function renderPathStroke(context: RenderContext, node: FigNode, matrix: SvgMatr
}) })
} }
function tryParsePathBlob(figJson: FigJson, blobIndex: number): ParsedPath | null {
const blob = figJson.blobs?.[blobIndex]
if (!blob) return null
return parsePathBlob(figJson, blobIndex)
}
function shouldRenderGradientStrokeAsPath(node: FigNode): boolean { function shouldRenderGradientStrokeAsPath(node: FigNode): boolean {
return Boolean(node.type === "ELLIPSE" && node.size && isFullEllipse(node.arcData)) return Boolean(node.type === "ELLIPSE" && node.size && isFullEllipse(node.arcData))
} }
@ -265,6 +439,9 @@ function getStraightSegments(d: string): Array<{ from: { x: number; y: number };
const next = readPoint() const next = readPoint()
segments.push({ from: current, to: next }) segments.push({ from: current, to: next })
current = next current = next
} else if (command === "Q") {
index += 2
current = readPoint()
} else if (command === "C") { } else if (command === "C") {
index += 4 index += 4
current = readPoint() current = readPoint()
@ -412,6 +589,8 @@ function renderGeometry(
return geometries.flatMap((geometry) => { return geometries.flatMap((geometry) => {
const parsed = parsePathBlob(context.figJson, geometry.commandsBlob) const parsed = parsePathBlob(context.figJson, geometry.commandsBlob)
if (!isFiniteBounds(parsed.bounds) || !parsed.d) return []
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"` : "" const fillRule = geometry.windingRule === "ODD" ? ` fill-rule="evenodd"` : ""
@ -419,6 +598,10 @@ function renderGeometry(
return paints return paints
.filter((paint) => paint.visible !== false) .filter((paint) => paint.visible !== false)
.map((paint) => { .map((paint) => {
if (paint.type === "IMAGE") {
return renderImageFill(context, node, parsed, paint, matrix, fillRule)
}
const fill = paintToSvgFill(context, node, parsed.bounds, paint, matrix) const fill = paintToSvgFill(context, node, parsed.bounds, paint, matrix)
const opacity = paintOpacityAttribute("fill", paint) const opacity = paintOpacityAttribute("fill", paint)
@ -427,6 +610,31 @@ function renderGeometry(
}) })
} }
function renderImageFill(
context: RenderContext,
node: FigNode,
parsed: ParsedPath,
paint: FigPaint,
matrix: SvgMatrix,
fillRule: string
): string {
const href = getImageDataUrl(context, paint)
const clipId = nextId(context, "clip")
const clipRule = fillRule ? fillRule.replace(" fill-rule=", " clip-rule=") : ""
const transformedPath = transformPathData(parsed.d, matrix)
const imageMatrix = getImagePaintMatrix(node, parsed.bounds, paint, matrix)
const opacity = imageOpacityAttribute(paint)
context.defs.push(`<clipPath id="${clipId}"><path d="${transformedPath}"${clipRule}/></clipPath>`)
// Figma stores image crop/fill as a normalized transform on the paint. Keep
// the clip on a wrapper <g>; resvg can drop the bitmap when clip-path is put
// directly on a transformed unit-sized <image>.
return `<g clip-path="url(#${clipId})"><image href="${href}" width="1" height="1" preserveAspectRatio="none" transform="matrix(${imageMatrix
.map(format)
.join(" ")})"${opacity}/></g>`
}
function parsePathBlob(figJson: FigJson, blobIndex: number): ParsedPath { function parsePathBlob(figJson: FigJson, blobIndex: number): ParsedPath {
const blob = figJson.blobs?.[blobIndex] const blob = figJson.blobs?.[blobIndex]
if (!blob) { if (!blob) {
@ -437,6 +645,7 @@ function parsePathBlob(figJson: FigJson, blobIndex: number): ParsedPath {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
let offset = 0 let offset = 0
let d = "" let d = ""
let hasOpenSubpath = false
const bounds = createEmptyBounds() const bounds = createEmptyBounds()
const readFloat = () => { const readFloat = () => {
@ -459,12 +668,18 @@ function parsePathBlob(figJson: FigJson, blobIndex: number): ParsedPath {
if (command === COMMAND_MOVE) { if (command === COMMAND_MOVE) {
d += `M ${readPoint()} ` d += `M ${readPoint()} `
hasOpenSubpath = true
} else if (command === COMMAND_LINE) { } else if (command === COMMAND_LINE) {
d += `L ${readPoint()} ` d += `L ${readPoint()} `
} else if (command === COMMAND_QUADRATIC) {
d += `Q ${readPoint()} ${readPoint()} `
} else if (command === COMMAND_CUBIC) { } else if (command === COMMAND_CUBIC) {
d += `C ${readPoint()} ${readPoint()} ${readPoint()} ` d += `C ${readPoint()} ${readPoint()} ${readPoint()} `
} else if (command === COMMAND_CLOSE) { } else if (command === COMMAND_CLOSE) {
if (hasOpenSubpath) {
d += "Z " d += "Z "
hasOpenSubpath = false
}
} else { } else {
throw new Error(`几何数据 blob ${blobIndex} 中存在不支持的向量命令:${command}`) throw new Error(`几何数据 blob ${blobIndex} 中存在不支持的向量命令:${command}`)
} }
@ -476,7 +691,7 @@ function parsePathBlob(figJson: FigJson, blobIndex: number): ParsedPath {
function transformPathData(d: string, matrix: SvgMatrix): string { function transformPathData(d: string, matrix: SvgMatrix): string {
if (matrix === IDENTITY) return d if (matrix === IDENTITY) return d
const tokens = d.match(/[MLCZ]|-?\d*\.?\d+(?:e[-+]?\d+)?/gi) ?? [] const tokens = d.match(/[MLQCZ]|-?\d*\.?\d+(?:e[-+]?\d+)?/gi) ?? []
const output: string[] = [] const output: string[] = []
let index = 0 let index = 0
@ -488,6 +703,8 @@ function transformPathData(d: string, matrix: SvgMatrix): string {
const command = tokens[index++] const command = tokens[index++]
if (command === "M" || command === "L") { if (command === "M" || command === "L") {
output.push(`${command} ${writePoint(readPoint())}`) output.push(`${command} ${writePoint(readPoint())}`)
} else if (command === "Q") {
output.push(`Q ${writePoint(readPoint())} ${writePoint(readPoint())}`)
} else if (command === "C") { } else if (command === "C") {
output.push(`C ${writePoint(readPoint())} ${writePoint(readPoint())} ${writePoint(readPoint())}`) output.push(`C ${writePoint(readPoint())} ${writePoint(readPoint())} ${writePoint(readPoint())}`)
} else if (command === "Z") { } else if (command === "Z") {
@ -529,6 +746,26 @@ function paintToSvgFill(
return `url(#${id})` return `url(#${id})`
} }
if (paint.type === "GRADIENT_RADIAL") {
const id = nextId(context, "gradient")
const gradientMatrix = getRadialGradientMatrix(node, pathBounds, paint, matrix)
const stops = paint.stops
?.map(
(stop) =>
`<stop offset="${format(stop.position * 100)}%" stop-color="${colorToCss(stop.color)}" stop-opacity="${format(
stop.color.a
)}"/>`
)
.join("")
context.defs.push(
`<radialGradient id="${id}" gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="matrix(${gradientMatrix
.map(format)
.join(" ")})">${stops ?? ""}</radialGradient>`
)
return `url(#${id})`
}
throw new Error(`不支持的填充类型:${paint.type}`) throw new Error(`不支持的填充类型:${paint.type}`)
} }
@ -554,6 +791,73 @@ function getLinearGradientLine(node: FigNode, pathBounds: Bounds, paint: FigPain
} }
} }
function getRadialGradientMatrix(node: FigNode, pathBounds: Bounds, paint: FigPaint, matrix: SvgMatrix): 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
const originY = pathBounds.minY < 0 ? pathBounds.minY : 0
const inverse = invert(toSvgMatrix(paint.transform))
const center = applyToPoint(inverse, 0, 0)
const xEdge = applyToPoint(inverse, 1, 0)
const yEdge = applyToPoint(inverse, 0, 1)
const localMatrix: SvgMatrix = [
(xEdge.x - center.x) * width,
(xEdge.y - center.y) * height,
(yEdge.x - center.x) * width,
(yEdge.y - center.y) * height,
originX + center.x * width,
originY + center.y * height
]
// Radial gradients use the same normalized paint transform as linear
// gradients, but SVG needs that unit circle projected into user space.
return multiply(matrix, localMatrix)
}
function getImageDataUrl(context: RenderContext, paint: FigPaint): string {
const candidates = [hashToHex(paint.image?.hash), hashToHex(paint.imageThumbnail?.hash)].filter(Boolean) as string[]
for (const hash of candidates) {
const asset = context.imageAssets.get(hash)
if (asset) return asset
}
throw new Error(`不支持的 IMAGE 填充:找不到本地图片资源 ${candidates.join(" 或 ") || "unknown"}`)
}
function getImagePaintMatrix(node: FigNode, pathBounds: Bounds, paint: FigPaint, matrix: SvgMatrix): 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
const originY = pathBounds.minY < 0 ? pathBounds.minY : 0
const transform = paint.transform ?? { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
const imageMatrix: SvgMatrix = [
transform.m00 * width,
transform.m10 * height,
transform.m01 * width,
transform.m11 * height,
originX + transform.m02 * width,
originY + transform.m12 * height
]
return multiply(matrix, imageMatrix)
}
function hashToHex(hash: Uint8Array | number[] | string | Record<string, number> | undefined): string | null {
if (!hash) return null
if (typeof hash === "string") return hash.toLowerCase()
const values =
hash instanceof Uint8Array
? [...hash]
: Array.isArray(hash)
? hash
: Object.keys(hash)
.sort((left, right) => Number(left) - Number(right))
.map((key) => (hash as Record<string, number>)[key])
return values.map((value) => value.toString(16).padStart(2, "0")).join("")
}
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")
const innerShadows = effects?.filter((effect) => effect.visible !== false && effect.type === "INNER_SHADOW") ?? [] const innerShadows = effects?.filter((effect) => effect.visible !== false && effect.type === "INNER_SHADOW") ?? []
@ -754,12 +1058,18 @@ function getRootExportBounds(target: FigNode, renderedBounds: Bounds | null, eff
// Native exports keep the node's nominal box, then extend the bitmap when // Native exports keep the node's nominal box, then extend the bitmap when
// visible filters such as foreground blur reach outside that box. // visible filters such as foreground blur reach outside that box.
if (shouldUseNominalRootBounds(target)) return targetBounds
return effectBounds ? unionBounds(targetBounds, effectBounds) : targetBounds return effectBounds ? unionBounds(targetBounds, effectBounds) : targetBounds
} }
return renderedBounds ?? { minX: 0, minY: 0, maxX: 0, maxY: 0 } return renderedBounds ?? { minX: 0, minY: 0, maxX: 0, maxY: 0 }
} }
function shouldUseNominalRootBounds(target: FigNode): boolean {
return Boolean(target.exportSettings?.some((setting) => setting.useAbsoluteBounds === false))
}
function getFigmaPixelSize(size: number, scale: number): number { function getFigmaPixelSize(size: number, scale: number): number {
const raw = size * scale const raw = size * scale
const rounded = Math.round(raw) const rounded = Math.round(raw)
@ -791,7 +1101,12 @@ function getGeometryLocalBounds(context: RenderContext, node: FigNode): Bounds |
let bounds: Bounds | null = null let bounds: Bounds | null = null
for (const geometry of geometries) { for (const geometry of geometries) {
const parsed = parsePathBlob(context.figJson, geometry.commandsBlob) // Some imported component assets keep stale fillGeometry blob references
// for invisible fills. Bounds should come from any remaining valid geometry
// instead of failing before visible strokeGeometry can render.
const parsed = tryParsePathBlob(context.figJson, geometry.commandsBlob)
if (!parsed || !isFiniteBounds(parsed.bounds)) continue
bounds = bounds ? unionBounds(bounds, parsed.bounds) : { ...parsed.bounds } bounds = bounds ? unionBounds(bounds, parsed.bounds) : { ...parsed.bounds }
} }
@ -875,6 +1190,17 @@ 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 isFiniteBounds(bounds: Bounds): boolean {
return (
Number.isFinite(bounds.minX) &&
Number.isFinite(bounds.minY) &&
Number.isFinite(bounds.maxX) &&
Number.isFinite(bounds.maxY) &&
bounds.maxX >= bounds.minX &&
bounds.maxY >= bounds.minY
)
}
function expandBounds(bounds: Bounds, amount: number): Bounds { function expandBounds(bounds: Bounds, amount: number): Bounds {
return { return {
minX: bounds.minX - amount, minX: bounds.minX - amount,
@ -993,6 +1319,11 @@ function paintOpacityAttribute(kind: "fill" | "stroke", paint: FigPaint): string
return opacity !== 1 ? ` ${kind}-opacity="${format(opacity)}"` : "" return opacity !== 1 ? ` ${kind}-opacity="${format(opacity)}"` : ""
} }
function imageOpacityAttribute(paint: FigPaint): string {
const opacity = paint.opacity ?? 1
return opacity !== 1 ? ` opacity="${format(opacity)}"` : ""
}
function strokeDashArrayAttribute(node: FigNode): string { function strokeDashArrayAttribute(node: FigNode): string {
const dashPattern = node.dashPattern?.filter((value) => value > 0) const dashPattern = node.dashPattern?.filter((value) => value > 0)
if (!dashPattern?.length) return "" if (!dashPattern?.length) return ""

View File

@ -32,6 +32,47 @@ export type FigPaint = {
visible?: boolean visible?: boolean
stops?: Array<{ color: FigColor; position: number }> stops?: Array<{ color: FigColor; position: number }>
transform?: FigmaMatrix transform?: FigmaMatrix
image?: { hash?: Uint8Array | number[] | string; name?: string }
imageThumbnail?: { hash?: Uint8Array | number[] | string; name?: string }
imageScaleMode?: "FILL" | "FIT" | "STRETCH" | "TILE" | string
originalImageWidth?: number
originalImageHeight?: number
}
export type FigTextGlyph = {
commandsBlob: number
position?: { x: number; y: number }
fontSize?: number
firstCharacter?: number
advance?: number
rotation?: number
}
export type FigTextStyleOverride = {
styleID?: number
fillPaints?: FigPaint[]
fontSize?: number
}
export type FigTextData = {
characters?: string
characterStyleIDs?: number[]
styleOverrideTable?: FigTextStyleOverride[]
lines?: unknown[]
}
export type FigDerivedTextData = {
glyphs?: FigTextGlyph[]
layoutSize?: { x: number; y: number }
baselines?: Array<{
position?: { x: number; y: number }
width?: number
lineY?: number
lineHeight?: number
lineAscent?: number
firstCharacter?: number
endCharacter?: number
}>
} }
export type FigGeometry = { export type FigGeometry = {
@ -62,12 +103,19 @@ export type FigNode = {
strokeWeight?: number strokeWeight?: number
strokeAlign?: "CENTER" | "INSIDE" | "OUTSIDE" strokeAlign?: "CENTER" | "INSIDE" | "OUTSIDE"
dashPattern?: number[] dashPattern?: number[]
frameMaskDisabled?: boolean
mask?: boolean
maskType?: "OUTLINE" | "ALPHA" | string
exportSettings?: Array<{ useAbsoluteBounds?: boolean; contentsOnly?: boolean; [key: string]: unknown }>
arcData?: FigArcData arcData?: FigArcData
fillPaints?: FigPaint[] fillPaints?: FigPaint[]
strokePaints?: FigPaint[] strokePaints?: FigPaint[]
fillGeometry?: FigGeometry[] fillGeometry?: FigGeometry[]
strokeGeometry?: FigGeometry[] strokeGeometry?: FigGeometry[]
effects?: FigEffect[] effects?: FigEffect[]
fontSize?: number
textData?: FigTextData
derivedTextData?: FigDerivedTextData
} }
export type FigJson = { export type FigJson = {