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 +4 -0
- package/bin/cli.mjs +2 -2
- package/lib/core/fig-deck.mjs +252 -1
- 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
|
@@ -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
|
}
|
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