@@ -9,6 +9,12 @@ function postToUi(message: PluginToUiMessage) {
99 figma . ui . postMessage ( message ) ;
1010}
1111
12+ type AssetExportFormat = "PNG" | "SVG" ;
13+
14+ type NormalizeContext = {
15+ assets : Map < string , NormalizedAsset > ;
16+ } ;
17+
1218function clonePaints ( node : SceneNode | PageNode , property : "fills" | "strokes" ) {
1319 if ( property === "fills" && "fills" in node ) {
1420 return node . fills === figma . mixed ? "mixed" : node . fills ;
@@ -21,20 +27,22 @@ function clonePaints(node: SceneNode | PageNode, property: "fills" | "strokes")
2127 return undefined ;
2228}
2329
24- function normalizePaints ( paints : unknown ) {
30+ function normalizePaints ( paints : unknown , assetId ?: string ) {
2531 if ( ! Array . isArray ( paints ) ) {
2632 return undefined ;
2733 }
2834
2935 return paints
3036 . filter ( ( paint ) => paint && typeof paint === "object" && "type" in paint )
3137 . map ( ( paint ) => {
32- const typedPaint = paint as SolidPaint ;
38+ const typedPaint = paint as Paint ;
3339 return {
3440 type : typedPaint . type ,
3541 visible : typedPaint . visible ,
3642 opacity : typedPaint . opacity ,
3743 imageHash : "imageHash" in typedPaint && typeof typedPaint . imageHash === "string" ? typedPaint . imageHash : undefined ,
44+ imageRef : "imageHash" in typedPaint && typeof typedPaint . imageHash === "string" ? typedPaint . imageHash : undefined ,
45+ asset_id : typedPaint . type === "IMAGE" ? assetId : undefined ,
3846 color : "color" in typedPaint ? typedPaint . color : undefined ,
3947 } ;
4048 } ) ;
@@ -46,6 +54,32 @@ function hasImagePaint(paints: unknown): boolean {
4654 } ) ;
4755}
4856
57+ function firstImageHash ( paints : unknown ) : string | undefined {
58+ if ( ! Array . isArray ( paints ) ) {
59+ return undefined ;
60+ }
61+
62+ for ( const paint of paints ) {
63+ if (
64+ paint &&
65+ typeof paint === "object" &&
66+ "type" in paint &&
67+ paint . type === "IMAGE" &&
68+ ( ! ( "visible" in paint ) || paint . visible !== false ) &&
69+ "imageHash" in paint &&
70+ typeof paint . imageHash === "string"
71+ ) {
72+ return paint . imageHash ;
73+ }
74+ }
75+
76+ return undefined ;
77+ }
78+
79+ function isVectorLikeNode ( node : SceneNode | PageNode ) : node is SceneNode {
80+ return [ "VECTOR" , "STAR" , "LINE" , "ELLIPSE" , "POLYGON" , "BOOLEAN_OPERATION" ] . includes ( node . type ) ;
81+ }
82+
4983function normalizeTextStyle ( node : TextNode ) {
5084 return {
5185 fontFamily : typeof node . fontName === "object" ? node . fontName . family : undefined ,
@@ -82,31 +116,44 @@ function normalizedNumberAssign(node: NormalizedSceneNode, key: "x" | "y" | "wid
82116 }
83117}
84118
85- async function normalizeNode ( node : SceneNode | PageNode , fallbackType ?: string ) : Promise < NormalizedSceneNode > {
119+ async function normalizeNode ( node : SceneNode | PageNode , context : NormalizeContext , fallbackType ?: string ) : Promise < NormalizedSceneNode > {
86120 const bounds = "absoluteBoundingBox" in node ? node . absoluteBoundingBox : null ;
87121 const fills = "fills" in node ? clonePaints ( node , "fills" ) : undefined ;
88122 const hasImageFill = hasImagePaint ( fills ) ;
123+ const shouldExportAsAsset = hasImageFill || isVectorLikeNode ( node ) ;
124+ const exportedAsset = shouldExportAsAsset && "exportAsync" in node
125+ ? await exportAsset ( node as SceneNode , isVectorLikeNode ( node ) ? "SVG" : "PNG" , firstImageHash ( fills ) )
126+ : null ;
127+ if ( exportedAsset ) {
128+ context . assets . set ( exportedAsset . id , exportedAsset ) ;
129+ }
89130 const normalized : NormalizedSceneNode = {
90131 id : node . id ,
91132 name : node . name ,
92- type : fallbackType || ( hasImageFill ? "IMAGE" : node . type ) ,
133+ type : fallbackType || ( shouldExportAsAsset && exportedAsset ? "IMAGE" : node . type ) ,
93134 visible : "visible" in node ? node . visible : true ,
94135 x : bounds ?. x ,
95136 y : bounds ?. y ,
96137 width : bounds ?. width ,
97138 height : bounds ?. height ,
98- fills : normalizePaints ( fills ) ,
99- strokes : "strokes" in node ? normalizePaints ( clonePaints ( node , "strokes" ) ) : undefined ,
139+ absoluteBoundingBox : bounds ? {
140+ x : bounds . x ,
141+ y : bounds . y ,
142+ width : bounds . width ,
143+ height : bounds . height ,
144+ } : undefined ,
145+ fills : normalizePaints ( fills , exportedAsset ?. id ) ,
146+ strokes : "strokes" in node ? normalizePaints ( clonePaints ( node , "strokes" ) , exportedAsset ?. id ) : undefined ,
100147 } ;
101148
102- if ( hasImageFill && "exportAsync" in node ) {
103- const asset = await exportAsset ( node as SceneNode ) ;
104- if ( asset ) {
105- normalized . image = {
106- dataUri : asset . dataUrl ,
107- alt : node . name ,
108- } ;
109- }
149+ if ( exportedAsset ) {
150+ normalized . asset_id = exportedAsset . id ;
151+ normalized . image = {
152+ asset_id : exportedAsset . id ,
153+ imageHash : exportedAsset . imageHash ,
154+ dataUri : exportedAsset . dataUrl ,
155+ alt : node . name ,
156+ } ;
110157 }
111158
112159 if ( node . type === "TEXT" ) {
@@ -123,7 +170,7 @@ async function normalizeNode(node: SceneNode | PageNode, fallbackType?: string):
123170 }
124171
125172 if ( "children" in node ) {
126- normalized . children = await Promise . all ( node . children . map ( ( child ) => normalizeNode ( child ) ) ) ;
173+ normalized . children = await Promise . all ( node . children . map ( ( child ) => normalizeNode ( child , context ) ) ) ;
127174 applyDerivedBounds ( normalized ) ;
128175 }
129176
@@ -134,22 +181,51 @@ function bytesToDataUrl(bytes: Uint8Array, mimeType: string) {
134181 return `data:${ mimeType } ;base64,${ figma . base64Encode ( bytes ) } ` ;
135182}
136183
137- async function exportAsset ( node : SceneNode ) : Promise < NormalizedAsset | null > {
184+ function assetId ( node : SceneNode , format : AssetExportFormat ) {
185+ return `${ node . id } :${ format . toLowerCase ( ) } ` ;
186+ }
187+
188+ function assetPath ( node : SceneNode , format : AssetExportFormat ) {
189+ const extension = format === "SVG" ? "svg" : "png" ;
190+ const slug = node . name
191+ . trim ( )
192+ . toLowerCase ( )
193+ . replace ( / [ ^ a - z 0 - 9 ] + / g, "-" )
194+ . replace ( / ^ - + | - + $ / g, "" ) || "asset" ;
195+
196+ return `assets/${ slug } -${ node . id . replace ( / [ ^ a - z 0 - 9 ] + / gi, "-" ) } .${ extension } ` ;
197+ }
198+
199+ function contentBase64FromDataUrl ( dataUrl : string ) {
200+ return dataUrl . replace ( / ^ d a t a : [ ^ ; , ] + ; b a s e 6 4 , / , "" ) ;
201+ }
202+
203+ async function exportAsset ( node : SceneNode , format : AssetExportFormat , imageHash ?: string ) : Promise < NormalizedAsset | null > {
138204 if ( ! ( "exportAsync" in node ) ) {
139205 return null ;
140206 }
141207
208+ const mimeType = format === "SVG" ? "image/svg+xml" : "image/png" ;
209+
142210 try {
143- const bytes = await node . exportAsync ( { format : "PNG" , constraint : { type : "SCALE" , value : 1 } } ) ;
211+ const bytes = format === "SVG"
212+ ? await node . exportAsync ( { format : "SVG" } )
213+ : await node . exportAsync ( { format : "PNG" , constraint : { type : "SCALE" , value : 1 } } ) ;
214+ const dataUrl = bytesToDataUrl ( bytes , mimeType ) ;
144215
145216 return {
146- id : node . id ,
147- name : `${ node . name } .png` ,
148- format : "PNG" ,
149- dataUrl : bytesToDataUrl ( bytes , "image/png" ) ,
217+ id : assetId ( node , format ) ,
218+ name : `${ node . name } .${ format . toLowerCase ( ) } ` ,
219+ format,
220+ dataUrl,
221+ mime_type : mimeType ,
222+ content_base64 : contentBase64FromDataUrl ( dataUrl ) ,
223+ path : assetPath ( node , format ) ,
224+ node_id : node . id ,
225+ imageHash,
150226 } ;
151227 } catch ( error ) {
152- console . warn ( "Unable to export selected node " , error ) ;
228+ console . warn ( "Unable to export Figma asset " , error ) ;
153229 return null ;
154230 }
155231}
@@ -159,7 +235,8 @@ async function getDocument(): Promise<NormalizedDocument> {
159235 await figma . loadAllPagesAsync ( ) ;
160236 }
161237
162- const pages = await Promise . all ( figma . root . children . map ( ( page ) => normalizeNode ( page , "PAGE" ) ) ) ;
238+ const context : NormalizeContext = { assets : new Map ( ) } ;
239+ const pages = await Promise . all ( figma . root . children . map ( ( page ) => normalizeNode ( page , context , "PAGE" ) ) ) ;
163240 const root : NormalizedSceneNode = {
164241 id : figma . root . id ,
165242 name : figma . root . name || "Figma document" ,
@@ -175,7 +252,7 @@ async function getDocument(): Promise<NormalizedDocument> {
175252 type : "DOCUMENT" ,
176253 exportedAt : new Date ( ) . toISOString ( ) ,
177254 root,
178- assets : [ ] ,
255+ assets : Array . from ( context . assets . values ( ) ) ,
179256 } ;
180257}
181258
0 commit comments