openfig-cli 0.3.22 → 0.3.24
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/lib/core/fig-deck.mjs +267 -19
- package/manifest.json +1 -1
- package/package.json +1 -1
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,242 @@ 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
|
+
// Remap blob indices (commandsBlob, vectorNetworkBlob, fillGeometry etc.)
|
|
370
|
+
// Blobs in source deck reference positions in sourceDeck.message.blobs.
|
|
371
|
+
// Copy each referenced blob to target and update the index.
|
|
372
|
+
const remapBlobIndex = (idx) => {
|
|
373
|
+
if (idx == null || idx < 0) return idx;
|
|
374
|
+
if (!sourceDeck.message.blobs || idx >= sourceDeck.message.blobs.length) return idx;
|
|
375
|
+
if (!this._blobRemap) this._blobRemap = new Map();
|
|
376
|
+
if (this._blobRemap.has(idx)) return this._blobRemap.get(idx);
|
|
377
|
+
const blob = sourceDeck.message.blobs[idx];
|
|
378
|
+
if (!this.message.blobs) this.message.blobs = [];
|
|
379
|
+
const newIdx = this.message.blobs.length;
|
|
380
|
+
this.message.blobs.push(deepClone(blob));
|
|
381
|
+
this._blobRemap.set(idx, newIdx);
|
|
382
|
+
return newIdx;
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
// fillGeometry[].commandsBlob
|
|
386
|
+
if (clone.fillGeometry) {
|
|
387
|
+
for (const fg of clone.fillGeometry) {
|
|
388
|
+
if (fg.commandsBlob != null) fg.commandsBlob = remapBlobIndex(fg.commandsBlob);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
// strokeGeometry[].commandsBlob
|
|
392
|
+
if (clone.strokeGeometry) {
|
|
393
|
+
for (const sg of clone.strokeGeometry) {
|
|
394
|
+
if (sg.commandsBlob != null) sg.commandsBlob = remapBlobIndex(sg.commandsBlob);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// vectorNetworkBlob (VECTOR nodes)
|
|
398
|
+
if (clone.vectorNetworkBlob != null) {
|
|
399
|
+
clone.vectorNetworkBlob = remapBlobIndex(clone.vectorNetworkBlob);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
clone.phase = 'CREATED';
|
|
403
|
+
delete clone.slideThumbnailHash;
|
|
404
|
+
delete clone.editInfo;
|
|
405
|
+
delete clone.prototypeInteractions;
|
|
406
|
+
|
|
407
|
+
return clone;
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// Clear blob remap cache after processing this symbol's subtree
|
|
411
|
+
delete this._blobRemap;
|
|
412
|
+
|
|
413
|
+
// Copy referenced images from source deck
|
|
414
|
+
if (sourceDeck.imagesDir) {
|
|
415
|
+
if (!this.imagesDir) {
|
|
416
|
+
// Create a temp images dir for the target deck
|
|
417
|
+
const tmp = this._tempDir || mkdtempSync(join(tmpdir(), 'openfig_'));
|
|
418
|
+
if (!this._tempDir) this._tempDir = tmp;
|
|
419
|
+
this.imagesDir = join(tmp, 'images');
|
|
420
|
+
mkdirSync(this.imagesDir, { recursive: true });
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
for (const clone of clonedNodes) {
|
|
424
|
+
// Copy IMAGE fills
|
|
425
|
+
if (clone.fillPaints) {
|
|
426
|
+
for (const paint of clone.fillPaints) {
|
|
427
|
+
if (paint.type === 'IMAGE' && paint.image?.name) {
|
|
428
|
+
this._copyImageAsset(sourceDeck.imagesDir, paint.image.name);
|
|
429
|
+
}
|
|
430
|
+
if (paint.imageThumbnail?.name) {
|
|
431
|
+
this._copyImageAsset(sourceDeck.imagesDir, paint.imageThumbnail.name);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Add cloned nodes to target deck
|
|
439
|
+
this.message.nodeChanges.push(...clonedNodes);
|
|
440
|
+
|
|
441
|
+
// Record mappings
|
|
442
|
+
for (const [oldId, newGuid] of idMap.entries()) {
|
|
443
|
+
globalIdMap.set(oldId, `${newGuid.sessionID}:${newGuid.localID}`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Register componentKey for dedup of subsequent symbols in the same call
|
|
447
|
+
if (sourceSymbol.componentKey) {
|
|
448
|
+
const newSymId = `${idMap.get(symId).sessionID}:${idMap.get(symId).localID}`;
|
|
449
|
+
existingByKey.set(sourceSymbol.componentKey, newSymId);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
this.rebuildMaps();
|
|
454
|
+
return globalIdMap;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Copy an image asset file from a source images directory to this deck's images directory.
|
|
459
|
+
* @param {string} srcImagesDir - Source images directory
|
|
460
|
+
* @param {string} fileName - Image file name (hash)
|
|
461
|
+
*/
|
|
462
|
+
_copyImageAsset(srcImagesDir, fileName) {
|
|
463
|
+
if (!fileName || !this.imagesDir) return;
|
|
464
|
+
const srcPath = join(srcImagesDir, fileName);
|
|
465
|
+
const destPath = join(this.imagesDir, fileName);
|
|
466
|
+
if (existsSync(srcPath) && !existsSync(destPath)) {
|
|
467
|
+
copyFileSync(srcPath, destPath);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
234
471
|
/**
|
|
235
472
|
* Encode message to canvas.fig binary.
|
|
236
473
|
* Returns a Promise<Uint8Array> because zstd-codec uses callbacks.
|
|
@@ -354,25 +591,36 @@ export class FigDeck {
|
|
|
354
591
|
const bgColor = extractColor(bgPaints) || { r: 1, g: 1, b: 1 };
|
|
355
592
|
const bgLum = luminance(bgColor.r, bgColor.g, bgColor.b);
|
|
356
593
|
|
|
357
|
-
// Walk all descendants looking for TEXT nodes
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
594
|
+
// Walk all descendants looking for TEXT nodes — follow INSTANCE → SYMBOL
|
|
595
|
+
// links so we reach TEXT inside components (walkTree alone stops at INSTANCE).
|
|
596
|
+
const walkIntoSymbols = (rootId, visited = new Set()) => {
|
|
597
|
+
this.walkTree(rootId, (node) => {
|
|
598
|
+
if (node.type === 'TEXT') {
|
|
599
|
+
const textPaints = node.fillPaints;
|
|
600
|
+
const textColor = extractColor(textPaints);
|
|
601
|
+
if (!textColor) return;
|
|
602
|
+
const textLum = luminance(textColor.r, textColor.g, textColor.b);
|
|
603
|
+
const ratio = contrastRatio(bgLum, textLum);
|
|
604
|
+
if (ratio < 2) {
|
|
605
|
+
const nodeId = nid(node);
|
|
606
|
+
let msg = `TEXT ${nodeId} "${node.name || ''}": contrast ratio ${ratio.toFixed(2)}:1 against slide background is below 2:1`;
|
|
607
|
+
if (node.colorVar) {
|
|
608
|
+
msg += ` (uses colorVar "${node.colorVar}" — may resolve differently in Figma)`;
|
|
609
|
+
}
|
|
610
|
+
warnings.push(msg);
|
|
611
|
+
}
|
|
372
612
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
613
|
+
if (node.type === 'INSTANCE' && node.symbolData?.symbolID) {
|
|
614
|
+
const sid = node.symbolData.symbolID;
|
|
615
|
+
const symNid = `${sid.sessionID}:${sid.localID}`;
|
|
616
|
+
if (!visited.has(symNid)) {
|
|
617
|
+
visited.add(symNid);
|
|
618
|
+
walkIntoSymbols(symNid, visited);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
};
|
|
623
|
+
walkIntoSymbols(slideId);
|
|
376
624
|
}
|
|
377
625
|
|
|
378
626
|
for (const w of warnings) console.warn(`⚠️ ${w}`);
|
package/manifest.json
CHANGED