openfig-cli 0.3.22 → 0.3.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,11 +14,12 @@ import { decompress } from 'fzstd';
14
14
  import { inflateRaw, deflateRaw } from 'pako';
15
15
  import { ZstdCodec } from 'zstd-codec';
16
16
  import yazl from 'yazl';
17
- import { readFileSync, createWriteStream, existsSync, mkdtempSync, readdirSync } from 'fs';
17
+ import { readFileSync, createWriteStream, existsSync, mkdtempSync, readdirSync, copyFileSync, mkdirSync } from 'fs';
18
18
  import { execSync } from 'child_process';
19
19
  import { join, resolve } from 'path';
20
20
  import { tmpdir } from 'os';
21
21
  import { nid } from './node-helpers.mjs';
22
+ import { deepClone } from './deep-clone.mjs';
22
23
 
23
24
  export class FigDeck {
24
25
  constructor() {
@@ -231,6 +232,242 @@ export class FigDeck {
231
232
  }
232
233
  }
233
234
 
235
+ /**
236
+ * Import SYMBOL nodes (and their full subtrees) from another FigDeck into this one.
237
+ *
238
+ * Handles:
239
+ * - Deep cloning with full GUID remapping
240
+ * - parentIndex.guid rebinding
241
+ * - symbolData.symbolID remapping for nested INSTANCE nodes
242
+ * - overrideKey remapping
243
+ * - Image/blob file copying between decks
244
+ * - Deduplication by componentKey
245
+ *
246
+ * @param {FigDeck} sourceDeck - The FigDeck to copy symbols from
247
+ * @param {string[]} symbolIds - Array of symbol node IDs in "s:l" format (e.g., ['1:500', '1:600'])
248
+ * @returns {Map<string, string>} Map of old ID → new ID for every remapped node
249
+ */
250
+ importSymbols(sourceDeck, symbolIds) {
251
+ if (!sourceDeck || !symbolIds?.length) return new Map();
252
+
253
+ // Build componentKey index for dedup in the target deck
254
+ const existingByKey = new Map();
255
+ for (const sym of this.getSymbols()) {
256
+ if (sym.phase === 'REMOVED' || !sym.componentKey) continue;
257
+ existingByKey.set(sym.componentKey, nid(sym));
258
+ }
259
+
260
+ // Find the Internal Only Canvas to parent imported symbols under
261
+ const internalCanvas = this.message.nodeChanges.find(
262
+ n => n.type === 'CANVAS' && n.name === 'Internal Only Canvas' && n.phase !== 'REMOVED'
263
+ );
264
+ if (!internalCanvas) {
265
+ throw new Error('No "Internal Only Canvas" found in target deck');
266
+ }
267
+ const canvasId = nid(internalCanvas);
268
+
269
+ let nextId = this.maxLocalID() + 1;
270
+ const sessionId = 1;
271
+ const globalIdMap = new Map(); // old "s:l" → new "s:l" across all imported symbols
272
+
273
+ for (const symId of symbolIds) {
274
+ const sourceSymbol = sourceDeck.getNode(symId);
275
+ if (!sourceSymbol || sourceSymbol.type !== 'SYMBOL') {
276
+ throw new Error(`SYMBOL not found in source deck: ${symId}`);
277
+ }
278
+
279
+ // Dedup: if target already has a symbol with the same componentKey, skip
280
+ if (sourceSymbol.componentKey && existingByKey.has(sourceSymbol.componentKey)) {
281
+ globalIdMap.set(symId, existingByKey.get(sourceSymbol.componentKey));
282
+ continue;
283
+ }
284
+
285
+ // Collect the full subtree (DFS)
286
+ const subtreeNodes = [];
287
+ sourceDeck.walkTree(symId, node => {
288
+ if (node.phase !== 'REMOVED') subtreeNodes.push(node);
289
+ });
290
+
291
+ // Build ID remap table for this subtree
292
+ const idMap = new Map(); // old "s:l" → new guid { sessionID, localID }
293
+ for (const node of subtreeNodes) {
294
+ idMap.set(nid(node), { sessionID: sessionId, localID: nextId++ });
295
+ }
296
+
297
+ // Clone and remap each node
298
+ const clonedNodes = subtreeNodes.map(node => {
299
+ const clone = deepClone(node);
300
+ const oldId = nid(node);
301
+ const newGuid = idMap.get(oldId);
302
+ if (newGuid) clone.guid = newGuid;
303
+
304
+ // Root symbol → parent under Internal Only Canvas
305
+ if (oldId === symId) {
306
+ clone.parentIndex = {
307
+ guid: deepClone(internalCanvas.guid),
308
+ position: String.fromCharCode(0x21 + this.getChildren(canvasId).length),
309
+ };
310
+ } else if (clone.parentIndex?.guid) {
311
+ // Remap parent reference
312
+ const parentOldId = `${clone.parentIndex.guid.sessionID}:${clone.parentIndex.guid.localID}`;
313
+ const remappedParent = idMap.get(parentOldId);
314
+ if (remappedParent) {
315
+ clone.parentIndex = { ...clone.parentIndex, guid: deepClone(remappedParent) };
316
+ }
317
+ }
318
+
319
+ // Remap symbolData.symbolID on INSTANCE nodes
320
+ if (clone.type === 'INSTANCE' && clone.symbolData?.symbolID) {
321
+ const sid = clone.symbolData.symbolID;
322
+ const symRef = `${sid.sessionID}:${sid.localID}`;
323
+ const remappedSym = idMap.get(symRef);
324
+ if (remappedSym) {
325
+ clone.symbolData.symbolID = deepClone(remappedSym);
326
+ } else if (globalIdMap.has(symRef)) {
327
+ // Reference to a previously imported symbol
328
+ const newRef = globalIdMap.get(symRef);
329
+ const [s, l] = newRef.split(':').map(Number);
330
+ clone.symbolData.symbolID = { sessionID: s, localID: l };
331
+ }
332
+ }
333
+
334
+ // Remap overrideKey
335
+ if (clone.overrideKey) {
336
+ const okOld = `${clone.overrideKey.sessionID}:${clone.overrideKey.localID}`;
337
+ const remappedOk = idMap.get(okOld);
338
+ if (remappedOk) {
339
+ clone.overrideKey = deepClone(remappedOk);
340
+ }
341
+ }
342
+
343
+ // Remap symbolOverrides guid paths
344
+ if (clone.symbolOverrides) {
345
+ for (const ov of clone.symbolOverrides) {
346
+ if (ov.guidPath?.guids) {
347
+ ov.guidPath.guids = ov.guidPath.guids.map(g => {
348
+ const gOld = `${g.sessionID}:${g.localID}`;
349
+ const remapped = idMap.get(gOld);
350
+ return remapped ? deepClone(remapped) : g;
351
+ });
352
+ }
353
+ }
354
+ }
355
+
356
+ // Remap derivedSymbolData references
357
+ if (clone.derivedSymbolData) {
358
+ for (const dsd of clone.derivedSymbolData) {
359
+ if (dsd.symbolID) {
360
+ const dsdOld = `${dsd.symbolID.sessionID}:${dsd.symbolID.localID}`;
361
+ const remappedDsd = idMap.get(dsdOld);
362
+ if (remappedDsd) {
363
+ dsd.symbolID = deepClone(remappedDsd);
364
+ }
365
+ }
366
+ }
367
+ }
368
+
369
+ // Remap blob indices (commandsBlob, vectorNetworkBlob, fillGeometry etc.)
370
+ // Blobs in source deck reference positions in sourceDeck.message.blobs.
371
+ // Copy each referenced blob to target and update the index.
372
+ const remapBlobIndex = (idx) => {
373
+ if (idx == null || idx < 0) return idx;
374
+ if (!sourceDeck.message.blobs || idx >= sourceDeck.message.blobs.length) return idx;
375
+ if (!this._blobRemap) this._blobRemap = new Map();
376
+ if (this._blobRemap.has(idx)) return this._blobRemap.get(idx);
377
+ const blob = sourceDeck.message.blobs[idx];
378
+ if (!this.message.blobs) this.message.blobs = [];
379
+ const newIdx = this.message.blobs.length;
380
+ this.message.blobs.push(deepClone(blob));
381
+ this._blobRemap.set(idx, newIdx);
382
+ return newIdx;
383
+ };
384
+
385
+ // fillGeometry[].commandsBlob
386
+ if (clone.fillGeometry) {
387
+ for (const fg of clone.fillGeometry) {
388
+ if (fg.commandsBlob != null) fg.commandsBlob = remapBlobIndex(fg.commandsBlob);
389
+ }
390
+ }
391
+ // strokeGeometry[].commandsBlob
392
+ if (clone.strokeGeometry) {
393
+ for (const sg of clone.strokeGeometry) {
394
+ if (sg.commandsBlob != null) sg.commandsBlob = remapBlobIndex(sg.commandsBlob);
395
+ }
396
+ }
397
+ // vectorNetworkBlob (VECTOR nodes)
398
+ if (clone.vectorNetworkBlob != null) {
399
+ clone.vectorNetworkBlob = remapBlobIndex(clone.vectorNetworkBlob);
400
+ }
401
+
402
+ clone.phase = 'CREATED';
403
+ delete clone.slideThumbnailHash;
404
+ delete clone.editInfo;
405
+ delete clone.prototypeInteractions;
406
+
407
+ return clone;
408
+ });
409
+
410
+ // Clear blob remap cache after processing this symbol's subtree
411
+ delete this._blobRemap;
412
+
413
+ // Copy referenced images from source deck
414
+ if (sourceDeck.imagesDir) {
415
+ if (!this.imagesDir) {
416
+ // Create a temp images dir for the target deck
417
+ const tmp = this._tempDir || mkdtempSync(join(tmpdir(), 'openfig_'));
418
+ if (!this._tempDir) this._tempDir = tmp;
419
+ this.imagesDir = join(tmp, 'images');
420
+ mkdirSync(this.imagesDir, { recursive: true });
421
+ }
422
+
423
+ for (const clone of clonedNodes) {
424
+ // Copy IMAGE fills
425
+ if (clone.fillPaints) {
426
+ for (const paint of clone.fillPaints) {
427
+ if (paint.type === 'IMAGE' && paint.image?.name) {
428
+ this._copyImageAsset(sourceDeck.imagesDir, paint.image.name);
429
+ }
430
+ if (paint.imageThumbnail?.name) {
431
+ this._copyImageAsset(sourceDeck.imagesDir, paint.imageThumbnail.name);
432
+ }
433
+ }
434
+ }
435
+ }
436
+ }
437
+
438
+ // Add cloned nodes to target deck
439
+ this.message.nodeChanges.push(...clonedNodes);
440
+
441
+ // Record mappings
442
+ for (const [oldId, newGuid] of idMap.entries()) {
443
+ globalIdMap.set(oldId, `${newGuid.sessionID}:${newGuid.localID}`);
444
+ }
445
+
446
+ // Register componentKey for dedup of subsequent symbols in the same call
447
+ if (sourceSymbol.componentKey) {
448
+ const newSymId = `${idMap.get(symId).sessionID}:${idMap.get(symId).localID}`;
449
+ existingByKey.set(sourceSymbol.componentKey, newSymId);
450
+ }
451
+ }
452
+
453
+ this.rebuildMaps();
454
+ return globalIdMap;
455
+ }
456
+
457
+ /**
458
+ * Copy an image asset file from a source images directory to this deck's images directory.
459
+ * @param {string} srcImagesDir - Source images directory
460
+ * @param {string} fileName - Image file name (hash)
461
+ */
462
+ _copyImageAsset(srcImagesDir, fileName) {
463
+ if (!fileName || !this.imagesDir) return;
464
+ const srcPath = join(srcImagesDir, fileName);
465
+ const destPath = join(this.imagesDir, fileName);
466
+ if (existsSync(srcPath) && !existsSync(destPath)) {
467
+ copyFileSync(srcPath, destPath);
468
+ }
469
+ }
470
+
234
471
  /**
235
472
  * Encode message to canvas.fig binary.
236
473
  * Returns a Promise<Uint8Array> because zstd-codec uses callbacks.
@@ -354,25 +591,36 @@ export class FigDeck {
354
591
  const bgColor = extractColor(bgPaints) || { r: 1, g: 1, b: 1 };
355
592
  const bgLum = luminance(bgColor.r, bgColor.g, bgColor.b);
356
593
 
357
- // Walk all descendants looking for TEXT nodes
358
- this.walkTree(slideId, (node) => {
359
- if (node.type !== 'TEXT') return;
360
- const textPaints = node.fillPaints;
361
- const textColor = extractColor(textPaints);
362
- if (!textColor) return;
363
-
364
- const textLum = luminance(textColor.r, textColor.g, textColor.b);
365
- const ratio = contrastRatio(bgLum, textLum);
366
-
367
- if (ratio < 2) {
368
- const nodeId = nid(node);
369
- let msg = `TEXT ${nodeId} "${node.name || ''}": contrast ratio ${ratio.toFixed(2)}:1 against slide background is below 2:1`;
370
- if (node.colorVar) {
371
- msg += ` (uses colorVar "${node.colorVar}" — may resolve differently in Figma)`;
594
+ // Walk all descendants looking for TEXT nodes — follow INSTANCE → SYMBOL
595
+ // links so we reach TEXT inside components (walkTree alone stops at INSTANCE).
596
+ const walkIntoSymbols = (rootId, visited = new Set()) => {
597
+ this.walkTree(rootId, (node) => {
598
+ if (node.type === 'TEXT') {
599
+ const textPaints = node.fillPaints;
600
+ const textColor = extractColor(textPaints);
601
+ if (!textColor) return;
602
+ const textLum = luminance(textColor.r, textColor.g, textColor.b);
603
+ const ratio = contrastRatio(bgLum, textLum);
604
+ if (ratio < 2) {
605
+ const nodeId = nid(node);
606
+ let msg = `TEXT ${nodeId} "${node.name || ''}": contrast ratio ${ratio.toFixed(2)}:1 against slide background is below 2:1`;
607
+ if (node.colorVar) {
608
+ msg += ` (uses colorVar "${node.colorVar}" — may resolve differently in Figma)`;
609
+ }
610
+ warnings.push(msg);
611
+ }
372
612
  }
373
- warnings.push(msg);
374
- }
375
- });
613
+ if (node.type === 'INSTANCE' && node.symbolData?.symbolID) {
614
+ const sid = node.symbolData.symbolID;
615
+ const symNid = `${sid.sessionID}:${sid.localID}`;
616
+ if (!visited.has(symNid)) {
617
+ visited.add(symNid);
618
+ walkIntoSymbols(symNid, visited);
619
+ }
620
+ }
621
+ });
622
+ };
623
+ walkIntoSymbols(slideId);
376
624
  }
377
625
 
378
626
  for (const w of warnings) console.warn(`⚠️ ${w}`);
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": "0.2",
3
3
  "name": "openfig",
4
- "version": "0.3.22",
4
+ "version": "0.3.24",
5
5
  "description": "Open-source tools for Figma file parsing and rendering",
6
6
  "author": {
7
7
  "name": "OpenFig Contributors"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openfig-cli",
3
- "version": "0.3.22",
3
+ "version": "0.3.24",
4
4
  "description": "OpenFig — Open-source tools for Figma file parsing and rendering",
5
5
  "type": "module",
6
6
  "bin": {