openfig-cli 0.3.21 → 0.3.23

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.
package/README.md CHANGED
@@ -53,6 +53,10 @@ npm install -g openfig-cli
53
53
 
54
54
  Node 18+. No build step. Pure ESM.
55
55
 
56
+ ## File Format Support
57
+
58
+ All CLI commands work on both `.deck` (Figma Slides) and `.fig` (Figma Design) files. Pass either format wherever a file path is expected.
59
+
56
60
  ## Quick Start
57
61
 
58
62
  ```bash
package/bin/cli.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * OpenFig — Open-source tools for Figma file parsing and rendering.
4
4
  *
5
- * Usage: openfig <command> [args...]
5
+ * Usage: openfig <command> <file.deck | file.fig> [args...]
6
6
  *
7
7
  * Commands:
8
8
  * inspect Show document structure (node hierarchy tree)
@@ -38,7 +38,7 @@ let command, rawArgs;
38
38
 
39
39
  if (!arg2 || arg2 === '--help' || arg2 === '-h') {
40
40
  console.log(`OpenFig — Open-source tools for Figma file parsing and rendering\n`);
41
- console.log('Usage: openfig <command> [args...]\n');
41
+ console.log('Usage: openfig <command> <file.deck | file.fig> [args...]\n');
42
42
  console.log('Commands:');
43
43
  console.log(' export Export slides as images (PNG/JPG/WEBP)');
44
44
  console.log(' pdf Export slides as a multi-page PDF');
@@ -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,206 @@ 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
+ clone.phase = 'CREATED';
370
+ delete clone.slideThumbnailHash;
371
+ delete clone.editInfo;
372
+ delete clone.prototypeInteractions;
373
+
374
+ return clone;
375
+ });
376
+
377
+ // Copy referenced images from source deck
378
+ if (sourceDeck.imagesDir) {
379
+ if (!this.imagesDir) {
380
+ // Create a temp images dir for the target deck
381
+ const tmp = this._tempDir || mkdtempSync(join(tmpdir(), 'openfig_'));
382
+ if (!this._tempDir) this._tempDir = tmp;
383
+ this.imagesDir = join(tmp, 'images');
384
+ mkdirSync(this.imagesDir, { recursive: true });
385
+ }
386
+
387
+ for (const clone of clonedNodes) {
388
+ // Copy IMAGE fills
389
+ if (clone.fillPaints) {
390
+ for (const paint of clone.fillPaints) {
391
+ if (paint.type === 'IMAGE' && paint.image?.name) {
392
+ this._copyImageAsset(sourceDeck.imagesDir, paint.image.name);
393
+ }
394
+ if (paint.imageThumbnail?.name) {
395
+ this._copyImageAsset(sourceDeck.imagesDir, paint.imageThumbnail.name);
396
+ }
397
+ }
398
+ }
399
+ }
400
+ }
401
+
402
+ // Add cloned nodes to target deck
403
+ this.message.nodeChanges.push(...clonedNodes);
404
+
405
+ // Record mappings
406
+ for (const [oldId, newGuid] of idMap.entries()) {
407
+ globalIdMap.set(oldId, `${newGuid.sessionID}:${newGuid.localID}`);
408
+ }
409
+
410
+ // Register componentKey for dedup of subsequent symbols in the same call
411
+ if (sourceSymbol.componentKey) {
412
+ const newSymId = `${idMap.get(symId).sessionID}:${idMap.get(symId).localID}`;
413
+ existingByKey.set(sourceSymbol.componentKey, newSymId);
414
+ }
415
+ }
416
+
417
+ this.rebuildMaps();
418
+ return globalIdMap;
419
+ }
420
+
421
+ /**
422
+ * Copy an image asset file from a source images directory to this deck's images directory.
423
+ * @param {string} srcImagesDir - Source images directory
424
+ * @param {string} fileName - Image file name (hash)
425
+ */
426
+ _copyImageAsset(srcImagesDir, fileName) {
427
+ if (!fileName || !this.imagesDir) return;
428
+ const srcPath = join(srcImagesDir, fileName);
429
+ const destPath = join(this.imagesDir, fileName);
430
+ if (existsSync(srcPath) && !existsSync(destPath)) {
431
+ copyFileSync(srcPath, destPath);
432
+ }
433
+ }
434
+
234
435
  /**
235
436
  * Encode message to canvas.fig binary.
236
437
  * Returns a Promise<Uint8Array> because zstd-codec uses callbacks.
@@ -325,6 +526,56 @@ export class FigDeck {
325
526
  }
326
527
  }
327
528
 
529
+ // --- Color contrast validation ---
530
+ // WCAG relative luminance
531
+ function luminance(r, g, b) {
532
+ const [rs, gs, bs] = [r, g, b].map(c =>
533
+ c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
534
+ );
535
+ return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
536
+ }
537
+
538
+ function contrastRatio(l1, l2) {
539
+ const lighter = Math.max(l1, l2);
540
+ const darker = Math.min(l1, l2);
541
+ return (lighter + 0.05) / (darker + 0.05);
542
+ }
543
+
544
+ function extractColor(paints) {
545
+ if (!paints || paints.length === 0) return null;
546
+ const paint = paints[0];
547
+ if (paint.color) return paint.color;
548
+ return null;
549
+ }
550
+
551
+ for (const slide of this.getActiveSlides()) {
552
+ const slideId = nid(slide);
553
+ // Get background color: from fillPaints or default white
554
+ const bgPaints = slide.fillPaints;
555
+ const bgColor = extractColor(bgPaints) || { r: 1, g: 1, b: 1 };
556
+ const bgLum = luminance(bgColor.r, bgColor.g, bgColor.b);
557
+
558
+ // Walk all descendants looking for TEXT nodes
559
+ this.walkTree(slideId, (node) => {
560
+ if (node.type !== 'TEXT') return;
561
+ const textPaints = node.fillPaints;
562
+ const textColor = extractColor(textPaints);
563
+ if (!textColor) return;
564
+
565
+ const textLum = luminance(textColor.r, textColor.g, textColor.b);
566
+ const ratio = contrastRatio(bgLum, textLum);
567
+
568
+ if (ratio < 2) {
569
+ const nodeId = nid(node);
570
+ let msg = `TEXT ${nodeId} "${node.name || ''}": contrast ratio ${ratio.toFixed(2)}:1 against slide background is below 2:1`;
571
+ if (node.colorVar) {
572
+ msg += ` (uses colorVar "${node.colorVar}" — may resolve differently in Figma)`;
573
+ }
574
+ warnings.push(msg);
575
+ }
576
+ });
577
+ }
578
+
328
579
  for (const w of warnings) console.warn(`⚠️ ${w}`);
329
580
  return warnings;
330
581
  }
@@ -169,6 +169,11 @@ export class Deck {
169
169
  // Auto-remove the original template blank slide on first addBlankSlide() call
170
170
  if (this._templateSlide) {
171
171
  this._templateSlide.phase = 'REMOVED';
172
+ // Also remove children (e.g. FRAME nodes) of the template slide
173
+ const templateChildren = fd.getChildren(nid(this._templateSlide));
174
+ for (const child of templateChildren) {
175
+ child.phase = 'REMOVED';
176
+ }
172
177
  this._templateSlide = null;
173
178
  fd.rebuildMaps();
174
179
  }
@@ -204,7 +209,6 @@ export class Deck {
204
209
  if (!templateSlide) throw new Error('No slides to clone structure from');
205
210
 
206
211
  const templateInst = fd.getSlideInstance(nid(templateSlide));
207
- if (!templateInst) throw new Error('Template slide has no instance');
208
212
 
209
213
  const slideRowId = templateSlide.parentIndex?.guid
210
214
  ? `${templateSlide.parentIndex.guid.sessionID}:${templateSlide.parentIndex.guid.localID}`
@@ -242,20 +246,42 @@ export class Deck {
242
246
  newSlide.transform.m02 = activeCount * 2160;
243
247
  }
244
248
 
245
- // Clone INSTANCE node, pointing at the given symbol
246
- const newInst = deepClone(templateInst);
247
- newInst.guid = { sessionID: 1, localID: instLocalId };
248
- newInst.name = newSlide.name;
249
- newInst.phase = 'CREATED';
250
- newInst.parentIndex = { guid: { sessionID: 1, localID: slideLocalId }, position: '!' };
251
- newInst.symbolData = {
252
- symbolID: deepClone(symbol._node.guid),
253
- symbolOverrides: [],
254
- uniformScaleFactor: 1,
255
- };
256
- delete newInst.derivedSymbolData;
257
- delete newInst.derivedSymbolDataLayoutVersion;
258
- delete newInst.editInfo;
249
+ // Build the INSTANCE node clone from existing INSTANCE or construct from
250
+ // scratch when the template SLIDE has a FRAME child instead (e.g. blank-template.deck).
251
+ let newInst;
252
+ if (templateInst) {
253
+ newInst = deepClone(templateInst);
254
+ newInst.guid = { sessionID: 1, localID: instLocalId };
255
+ newInst.name = newSlide.name;
256
+ newInst.phase = 'CREATED';
257
+ newInst.parentIndex = { guid: { sessionID: 1, localID: slideLocalId }, position: '!' };
258
+ newInst.symbolData = {
259
+ symbolID: deepClone(symbol._node.guid),
260
+ symbolOverrides: [],
261
+ uniformScaleFactor: 1,
262
+ };
263
+ delete newInst.derivedSymbolData;
264
+ delete newInst.derivedSymbolDataLayoutVersion;
265
+ delete newInst.editInfo;
266
+ } else {
267
+ // No INSTANCE to clone (FRAME-based template) — build a bare INSTANCE node
268
+ newInst = {
269
+ guid: { sessionID: 1, localID: instLocalId },
270
+ phase: 'CREATED',
271
+ parentIndex: { guid: { sessionID: 1, localID: slideLocalId }, position: '!' },
272
+ type: 'INSTANCE',
273
+ name: newSlide.name,
274
+ visible: true,
275
+ opacity: 1,
276
+ size: deepClone(templateSlide.size ?? { x: 1920, y: 1080 }),
277
+ transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
278
+ symbolData: {
279
+ symbolID: deepClone(symbol._node.guid),
280
+ symbolOverrides: [],
281
+ uniformScaleFactor: 1,
282
+ },
283
+ };
284
+ }
259
285
 
260
286
  fd.message.nodeChanges.push(newSlide);
261
287
  fd.message.nodeChanges.push(newInst);
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": "0.2",
3
3
  "name": "openfig",
4
- "version": "0.3.21",
4
+ "version": "0.3.23",
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.21",
3
+ "version": "0.3.23",
4
4
  "description": "OpenFig — Open-source tools for Figma file parsing and rendering",
5
5
  "type": "module",
6
6
  "bin": {