openfig-cli 0.3.20 → 0.3.22

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');
@@ -325,6 +325,56 @@ export class FigDeck {
325
325
  }
326
326
  }
327
327
 
328
+ // --- Color contrast validation ---
329
+ // WCAG relative luminance
330
+ function luminance(r, g, b) {
331
+ const [rs, gs, bs] = [r, g, b].map(c =>
332
+ c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
333
+ );
334
+ return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
335
+ }
336
+
337
+ function contrastRatio(l1, l2) {
338
+ const lighter = Math.max(l1, l2);
339
+ const darker = Math.min(l1, l2);
340
+ return (lighter + 0.05) / (darker + 0.05);
341
+ }
342
+
343
+ function extractColor(paints) {
344
+ if (!paints || paints.length === 0) return null;
345
+ const paint = paints[0];
346
+ if (paint.color) return paint.color;
347
+ return null;
348
+ }
349
+
350
+ for (const slide of this.getActiveSlides()) {
351
+ const slideId = nid(slide);
352
+ // Get background color: from fillPaints or default white
353
+ const bgPaints = slide.fillPaints;
354
+ const bgColor = extractColor(bgPaints) || { r: 1, g: 1, b: 1 };
355
+ const bgLum = luminance(bgColor.r, bgColor.g, bgColor.b);
356
+
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)`;
372
+ }
373
+ warnings.push(msg);
374
+ }
375
+ });
376
+ }
377
+
328
378
  for (const w of warnings) console.warn(`⚠️ ${w}`);
329
379
  return warnings;
330
380
  }
@@ -1102,6 +1102,22 @@ function renderInstance(deck, node) {
1102
1102
  const symbol = deck.getNode(symNid);
1103
1103
  if (!symbol) return renderPlaceholder(deck, node);
1104
1104
 
1105
+ // Figma-parity: reject instances of symbols with invalid variant specs.
1106
+ // Figma Desktop silently shows blank slides for these. We match that behavior
1107
+ // deliberately so our preview catches invalid decks instead of hiding them.
1108
+ // Root cause: older tooling created SYMBOL variants with names (e.g.
1109
+ // "Size=Wide") that don't exist in the component set's variantPropSpecs.
1110
+ if (symbol.componentKey && symbol.variantPropSpecs) {
1111
+ const specValues = new Set(symbol.variantPropSpecs.map(s => s.value));
1112
+ const nameValues = (symbol.name || '').split(', ').map(p => p.split('=')[1]).filter(Boolean);
1113
+ const invalid = nameValues.some(v => !specValues.has(v));
1114
+ if (invalid) {
1115
+ const nid = `${node.guid.sessionID}:${node.guid.localID}`;
1116
+ console.warn(`⚠️ Skipping INSTANCE ${nid}: symbol ${symNid} "${symbol.name}" has invalid variant specs — Figma would show blank`);
1117
+ return renderPlaceholder(deck, node);
1118
+ }
1119
+ }
1120
+
1105
1121
  // Temporarily apply symbolOverrides so rendered content reflects overrides.
1106
1122
  // Override guidPaths may reference library-original IDs (e.g. 100:656) rather
1107
1123
  // than local node IDs (e.g. 1:1131). Nodes expose their library ID via the
@@ -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.20",
4
+ "version": "0.3.22",
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.20",
3
+ "version": "0.3.22",
4
4
  "description": "OpenFig — Open-source tools for Figma file parsing and rendering",
5
5
  "type": "module",
6
6
  "bin": {