openfig-cli 0.3.23 → 0.3.25

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.
@@ -20,6 +20,7 @@ import { join, resolve } from 'path';
20
20
  import { tmpdir } from 'os';
21
21
  import { nid } from './node-helpers.mjs';
22
22
  import { deepClone } from './deep-clone.mjs';
23
+ import { hashToHex } from './image-helpers.mjs';
23
24
 
24
25
  export class FigDeck {
25
26
  constructor() {
@@ -366,6 +367,39 @@ export class FigDeck {
366
367
  }
367
368
  }
368
369
 
370
+ // Remap blob indices (commandsBlob, vectorNetworkBlob, fillGeometry etc.)
371
+ // Blobs in source deck reference positions in sourceDeck.message.blobs.
372
+ // Copy each referenced blob to target and update the index.
373
+ const remapBlobIndex = (idx) => {
374
+ if (idx == null || idx < 0) return idx;
375
+ if (!sourceDeck.message.blobs || idx >= sourceDeck.message.blobs.length) return idx;
376
+ if (!this._blobRemap) this._blobRemap = new Map();
377
+ if (this._blobRemap.has(idx)) return this._blobRemap.get(idx);
378
+ const blob = sourceDeck.message.blobs[idx];
379
+ if (!this.message.blobs) this.message.blobs = [];
380
+ const newIdx = this.message.blobs.length;
381
+ this.message.blobs.push(deepClone(blob));
382
+ this._blobRemap.set(idx, newIdx);
383
+ return newIdx;
384
+ };
385
+
386
+ // fillGeometry[].commandsBlob
387
+ if (clone.fillGeometry) {
388
+ for (const fg of clone.fillGeometry) {
389
+ if (fg.commandsBlob != null) fg.commandsBlob = remapBlobIndex(fg.commandsBlob);
390
+ }
391
+ }
392
+ // strokeGeometry[].commandsBlob
393
+ if (clone.strokeGeometry) {
394
+ for (const sg of clone.strokeGeometry) {
395
+ if (sg.commandsBlob != null) sg.commandsBlob = remapBlobIndex(sg.commandsBlob);
396
+ }
397
+ }
398
+ // vectorNetworkBlob (VECTOR nodes)
399
+ if (clone.vectorNetworkBlob != null) {
400
+ clone.vectorNetworkBlob = remapBlobIndex(clone.vectorNetworkBlob);
401
+ }
402
+
369
403
  clone.phase = 'CREATED';
370
404
  delete clone.slideThumbnailHash;
371
405
  delete clone.editInfo;
@@ -374,6 +408,9 @@ export class FigDeck {
374
408
  return clone;
375
409
  });
376
410
 
411
+ // Clear blob remap cache after processing this symbol's subtree
412
+ delete this._blobRemap;
413
+
377
414
  // Copy referenced images from source deck
378
415
  if (sourceDeck.imagesDir) {
379
416
  if (!this.imagesDir) {
@@ -384,15 +421,28 @@ export class FigDeck {
384
421
  mkdirSync(this.imagesDir, { recursive: true });
385
422
  }
386
423
 
424
+ const copyImagePaint = (paint) => {
425
+ if (!paint) return;
426
+ if (paint.type === 'IMAGE' || paint.image) {
427
+ const name = paint.image?.name || (paint.image?.hash?.length ? hashToHex(paint.image.hash) : null);
428
+ if (name) this._copyImageAsset(sourceDeck.imagesDir, name);
429
+ }
430
+ if (paint.imageThumbnail) {
431
+ const tName = paint.imageThumbnail.name || (paint.imageThumbnail.hash?.length ? hashToHex(paint.imageThumbnail.hash) : null);
432
+ if (tName) this._copyImageAsset(sourceDeck.imagesDir, tName);
433
+ }
434
+ };
435
+
387
436
  for (const clone of clonedNodes) {
388
- // Copy IMAGE fills
437
+ // Copy IMAGE fills from node fillPaints
389
438
  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);
439
+ for (const paint of clone.fillPaints) copyImagePaint(paint);
440
+ }
441
+ // Copy images from symbolOverrides (INSTANCE nodes)
442
+ if (clone.symbolData?.symbolOverrides) {
443
+ for (const ov of clone.symbolData.symbolOverrides) {
444
+ if (ov.fillPaints) {
445
+ for (const paint of ov.fillPaints) copyImagePaint(paint);
396
446
  }
397
447
  }
398
448
  }
@@ -555,25 +605,36 @@ export class FigDeck {
555
605
  const bgColor = extractColor(bgPaints) || { r: 1, g: 1, b: 1 };
556
606
  const bgLum = luminance(bgColor.r, bgColor.g, bgColor.b);
557
607
 
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)`;
608
+ // Walk all descendants looking for TEXT nodes — follow INSTANCE → SYMBOL
609
+ // links so we reach TEXT inside components (walkTree alone stops at INSTANCE).
610
+ const walkIntoSymbols = (rootId, visited = new Set()) => {
611
+ this.walkTree(rootId, (node) => {
612
+ if (node.type === 'TEXT') {
613
+ const textPaints = node.fillPaints;
614
+ const textColor = extractColor(textPaints);
615
+ if (!textColor) return;
616
+ const textLum = luminance(textColor.r, textColor.g, textColor.b);
617
+ const ratio = contrastRatio(bgLum, textLum);
618
+ if (ratio < 2) {
619
+ const nodeId = nid(node);
620
+ let msg = `TEXT ${nodeId} "${node.name || ''}": contrast ratio ${ratio.toFixed(2)}:1 against slide background is below 2:1`;
621
+ if (node.colorVar) {
622
+ msg += ` (uses colorVar "${node.colorVar}" — may resolve differently in Figma)`;
623
+ }
624
+ warnings.push(msg);
625
+ }
573
626
  }
574
- warnings.push(msg);
575
- }
576
- });
627
+ if (node.type === 'INSTANCE' && node.symbolData?.symbolID) {
628
+ const sid = node.symbolData.symbolID;
629
+ const symNid = `${sid.sessionID}:${sid.localID}`;
630
+ if (!visited.has(symNid)) {
631
+ visited.add(symNid);
632
+ walkIntoSymbols(symNid, visited);
633
+ }
634
+ }
635
+ });
636
+ };
637
+ walkIntoSymbols(slideId);
577
638
  }
578
639
 
579
640
  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.23",
4
+ "version": "0.3.25",
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.23",
3
+ "version": "0.3.25",
4
4
  "description": "OpenFig — Open-source tools for Figma file parsing and rendering",
5
5
  "type": "module",
6
6
  "bin": {