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 +4 -0
- package/bin/cli.mjs +2 -2
- package/lib/core/fig-deck.mjs +50 -0
- package/lib/rasterizer/svg-builder.mjs +16 -0
- package/lib/slides/api.mjs +41 -15
- package/manifest.json +1 -1
- package/package.json +1 -1
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');
|
package/lib/core/fig-deck.mjs
CHANGED
|
@@ -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
|
package/lib/slides/api.mjs
CHANGED
|
@@ -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
|
-
//
|
|
246
|
-
|
|
247
|
-
newInst
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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