From 1e3d3ee6a1e4c14b28c19697f9a8b8704070d06c Mon Sep 17 00:00:00 2001 From: sunlei Date: Sun, 24 May 2026 19:22:21 +0800 Subject: [PATCH] feat: support local fig image assets in svg export --- README.md | 2 + src/services/export-node.ts | 5 +- src/services/fig-images.ts | 58 ++++++ src/services/fig-node-svg.ts | 343 ++++++++++++++++++++++++++++++++++- src/services/fig-types.ts | 48 +++++ 5 files changed, 449 insertions(+), 7 deletions(-) create mode 100644 src/services/fig-images.ts diff --git a/README.md b/README.md index de60f24..6319965 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,8 @@ - `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。 +当前本地 SVG/PNG 管线支持 `.fig` 外层 zip 中的 `images/` 图片资源,会把 `IMAGE` 填充按真实图片、paint transform 和矢量路径裁切导出;如果本地文件缺少对应图片资源,会明确报出缺失的 IMAGE hash。线性/径向渐变、虚线描边和部分滤镜也会转换为 SVG 近似表达。 + 导出结果还会包含 `exportCapabilities`: - `localSvg.supported: true`:支持从本地 `.fig` 解码生成 SVG。 diff --git a/src/services/export-node.ts b/src/services/export-node.ts index 28e8b35..a316ff8 100644 --- a/src/services/export-node.ts +++ b/src/services/export-node.ts @@ -3,6 +3,7 @@ import path from "node:path" import zlib from "node:zlib" import { Resvg } from "@resvg/resvg-js" import { loadFigFile } from "./fig-file.js" +import { loadFigImageAssets } from "./fig-images.js" import { renderNodeToSvg, type FigmaLikeRasterHint } from "./fig-node-svg.js" import { keyForGuid, sanitizeFilePart } from "../utils/node-id.js" @@ -61,13 +62,15 @@ export type ExportNodeResult = { export function exportFigNode(options: ExportNodeOptions): ExportNodeResult { const figJson = loadFigFile(options.filePath) + const imageAssets = loadFigImageAssets(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, - pngFigmaLike: useFigmaLikePng + pngFigmaLike: useFigmaLikePng, + imageAssets }) const outputPath = path.resolve(options.outputPath ?? defaultOutputPath(options)) const node = { diff --git a/src/services/fig-images.ts b/src/services/fig-images.ts new file mode 100644 index 0000000..9c758a8 --- /dev/null +++ b/src/services/fig-images.ts @@ -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 + +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 +} diff --git a/src/services/fig-node-svg.ts b/src/services/fig-node-svg.ts index de933bf..b7e2d9b 100644 --- a/src/services/fig-node-svg.ts +++ b/src/services/fig-node-svg.ts @@ -7,8 +7,10 @@ import type { FigJson, FigNode, FigPaint, + FigTextGlyph, FigmaMatrix } from "./fig-types.js" +import type { FigImageAssets } from "./fig-images.js" import { keyForGuid, normalizeNodeId } from "../utils/node-id.js" type SvgMatrix = [number, number, number, number, number, number] @@ -23,6 +25,7 @@ type RenderContext = { childrenByParent: Map defs: string[] rasterHints: FigmaLikeRasterHint[] + imageAssets: FigImageAssets bounds: Bounds | null effectBounds: Bounds | null idSeed: number @@ -49,6 +52,7 @@ export type RenderOptions = { scale?: number background?: string pngFigmaLike?: boolean + imageAssets?: FigImageAssets } export type RenderedSvg = { @@ -63,6 +67,7 @@ export type RenderedSvg = { const IDENTITY: SvgMatrix = [1, 0, 0, 1, 0, 0] const COMMAND_MOVE = 1 const COMMAND_LINE = 2 +const COMMAND_QUADRATIC = 3 const COMMAND_CUBIC = 4 const COMMAND_CLOSE = 0 @@ -74,6 +79,7 @@ export function renderNodeToSvg(figJson: FigJson, options: RenderOptions): Rende childrenByParent, defs: [], rasterHints: [], + imageAssets: options.imageAssets ?? new Map(), bounds: null, effectBounds: null, idSeed: 0, @@ -163,9 +169,10 @@ function renderNodeSubtree( node.type !== "BOOLEAN_OPERATION" || !(node.fillGeometry?.length || node.strokeGeometry?.length) const nodeContent = [ ...renderGeometry(context, node, node.fillGeometry, node.fillPaints, matrix), + renderTextNode(context, node, matrix), collectFigmaLikeEllipseInnerShadowHint(context, node, matrix), ...renderStrokeGeometry(context, node, matrix), - ...(shouldRenderChildren ? getSortedChildren(context, node).map((child) => renderNodeSubtree(context, child, matrix)) : []) + ...(shouldRenderChildren ? renderChildNodes(context, node, matrix) : []) ].join("") if (!nodeContent) return "" @@ -177,12 +184,170 @@ function renderNodeSubtree( return `${nodeContent}` } +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 ? `${rendered}` : 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(`${content}`) + 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 `` + }) +} + +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 `` + }) ?? [] + ) +} + +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() + 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 `` + }) +} + +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[] { const outsideEllipseStroke = renderOutsideEllipseStroke(context, node, matrix) if (outsideEllipseStroke) return outsideEllipseStroke const pathStroke = renderPathStroke(context, node, matrix) - if (pathStroke) return pathStroke + if (pathStroke?.length) return pathStroke 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 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) includeBounds(context, transformBounds(matrix, expandedBounds)) 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 { 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() segments.push({ from: current, to: next }) current = next + } else if (command === "Q") { + index += 2 + current = readPoint() } else if (command === "C") { index += 4 current = readPoint() @@ -412,6 +589,8 @@ function renderGeometry( return geometries.flatMap((geometry) => { const parsed = parsePathBlob(context.figJson, geometry.commandsBlob) + if (!isFiniteBounds(parsed.bounds) || !parsed.d) return [] + const transformedBounds = transformBounds(matrix, parsed.bounds) includeBounds(context, transformedBounds) const fillRule = geometry.windingRule === "ODD" ? ` fill-rule="evenodd"` : "" @@ -419,6 +598,10 @@ function renderGeometry( return paints .filter((paint) => paint.visible !== false) .map((paint) => { + if (paint.type === "IMAGE") { + return renderImageFill(context, node, parsed, paint, matrix, fillRule) + } + const fill = paintToSvgFill(context, node, parsed.bounds, paint, matrix) 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(``) + + // Figma stores image crop/fill as a normalized transform on the paint. Keep + // the clip on a wrapper ; resvg can drop the bitmap when clip-path is put + // directly on a transformed unit-sized . + return `` +} + function parsePathBlob(figJson: FigJson, blobIndex: number): ParsedPath { const blob = figJson.blobs?.[blobIndex] if (!blob) { @@ -437,6 +645,7 @@ function parsePathBlob(figJson: FigJson, blobIndex: number): ParsedPath { const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) let offset = 0 let d = "" + let hasOpenSubpath = false const bounds = createEmptyBounds() const readFloat = () => { @@ -459,12 +668,18 @@ function parsePathBlob(figJson: FigJson, blobIndex: number): ParsedPath { if (command === COMMAND_MOVE) { d += `M ${readPoint()} ` + hasOpenSubpath = true } else if (command === COMMAND_LINE) { d += `L ${readPoint()} ` + } else if (command === COMMAND_QUADRATIC) { + d += `Q ${readPoint()} ${readPoint()} ` } else if (command === COMMAND_CUBIC) { d += `C ${readPoint()} ${readPoint()} ${readPoint()} ` } else if (command === COMMAND_CLOSE) { - d += "Z " + if (hasOpenSubpath) { + d += "Z " + hasOpenSubpath = false + } } else { throw new Error(`几何数据 blob ${blobIndex} 中存在不支持的向量命令:${command}`) } @@ -476,7 +691,7 @@ function parsePathBlob(figJson: FigJson, blobIndex: number): ParsedPath { function transformPathData(d: string, matrix: SvgMatrix): string { 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[] = [] let index = 0 @@ -488,6 +703,8 @@ function transformPathData(d: string, matrix: SvgMatrix): string { const command = tokens[index++] if (command === "M" || command === "L") { output.push(`${command} ${writePoint(readPoint())}`) + } else if (command === "Q") { + output.push(`Q ${writePoint(readPoint())} ${writePoint(readPoint())}`) } else if (command === "C") { output.push(`C ${writePoint(readPoint())} ${writePoint(readPoint())} ${writePoint(readPoint())}`) } else if (command === "Z") { @@ -529,6 +746,26 @@ function paintToSvgFill( 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) => + `` + ) + .join("") + + context.defs.push( + `${stops ?? ""}` + ) + return `url(#${id})` + } + 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 | 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)[key]) + + return values.map((value) => value.toString(16).padStart(2, "0")).join("") +} + function createFilter(context: RenderContext, effects: FigEffect[] | undefined, bounds: Bounds): string | null { const shadow = effects?.find((effect) => effect.visible !== false && effect.type === "DROP_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 // visible filters such as foreground blur reach outside that box. + if (shouldUseNominalRootBounds(target)) return targetBounds + return effectBounds ? unionBounds(targetBounds, effectBounds) : targetBounds } 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 { const raw = size * scale const rounded = Math.round(raw) @@ -791,7 +1101,12 @@ function getGeometryLocalBounds(context: RenderContext, node: FigNode): Bounds | let bounds: Bounds | null = null 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 } } @@ -875,6 +1190,17 @@ function transformBounds(matrix: SvgMatrix, bounds: Bounds): Bounds { 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 { return { minX: bounds.minX - amount, @@ -993,6 +1319,11 @@ function paintOpacityAttribute(kind: "fill" | "stroke", paint: FigPaint): string 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 { const dashPattern = node.dashPattern?.filter((value) => value > 0) if (!dashPattern?.length) return "" diff --git a/src/services/fig-types.ts b/src/services/fig-types.ts index b9e4523..3986ddd 100644 --- a/src/services/fig-types.ts +++ b/src/services/fig-types.ts @@ -32,6 +32,47 @@ export type FigPaint = { visible?: boolean stops?: Array<{ color: FigColor; position: number }> 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 = { @@ -62,12 +103,19 @@ export type FigNode = { strokeWeight?: number strokeAlign?: "CENTER" | "INSIDE" | "OUTSIDE" dashPattern?: number[] + frameMaskDisabled?: boolean + mask?: boolean + maskType?: "OUTLINE" | "ALPHA" | string + exportSettings?: Array<{ useAbsoluteBounds?: boolean; contentsOnly?: boolean; [key: string]: unknown }> arcData?: FigArcData fillPaints?: FigPaint[] strokePaints?: FigPaint[] fillGeometry?: FigGeometry[] strokeGeometry?: FigGeometry[] effects?: FigEffect[] + fontSize?: number + textData?: FigTextData + derivedTextData?: FigDerivedTextData } export type FigJson = {