openfig-cli 0.3.11
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/LICENSE +21 -0
- package/README.md +95 -0
- package/bin/cli.mjs +111 -0
- package/bin/commands/clone-slide.mjs +153 -0
- package/bin/commands/export.mjs +83 -0
- package/bin/commands/insert-image.mjs +90 -0
- package/bin/commands/inspect.mjs +91 -0
- package/bin/commands/list-overrides.mjs +66 -0
- package/bin/commands/list-text.mjs +60 -0
- package/bin/commands/remove-slide.mjs +47 -0
- package/bin/commands/roundtrip.mjs +37 -0
- package/bin/commands/update-text.mjs +79 -0
- package/lib/core/deep-clone.mjs +16 -0
- package/lib/core/fig-deck.mjs +332 -0
- package/lib/core/image-helpers.mjs +56 -0
- package/lib/core/image-utils.mjs +29 -0
- package/lib/core/node-helpers.mjs +49 -0
- package/lib/rasterizer/deck-rasterizer.mjs +233 -0
- package/lib/rasterizer/download-font.mjs +57 -0
- package/lib/rasterizer/font-resolver.mjs +602 -0
- package/lib/rasterizer/fonts/DarkerGrotesque-400.ttf +0 -0
- package/lib/rasterizer/fonts/DarkerGrotesque-500.ttf +0 -0
- package/lib/rasterizer/fonts/DarkerGrotesque-600.ttf +0 -0
- package/lib/rasterizer/fonts/Inter-Regular.ttf +0 -0
- package/lib/rasterizer/fonts/Inter-Variable.ttf +0 -0
- package/lib/rasterizer/fonts/Inter.var.woff2 +0 -0
- package/lib/rasterizer/fonts/avenir-next-bold-italic.ttf +0 -0
- package/lib/rasterizer/fonts/avenir-next-bold.ttf +0 -0
- package/lib/rasterizer/fonts/avenir-next-demibold-italic.ttf +0 -0
- package/lib/rasterizer/fonts/avenir-next-demibold.ttf +0 -0
- package/lib/rasterizer/fonts/avenir-next-italic.ttf +0 -0
- package/lib/rasterizer/fonts/avenir-next-medium-italic.ttf +0 -0
- package/lib/rasterizer/fonts/avenir-next-medium.ttf +0 -0
- package/lib/rasterizer/fonts/avenir-next-regular.ttf +0 -0
- package/lib/rasterizer/fonts/darker-grotesque-patched-400-normal.woff2 +0 -0
- package/lib/rasterizer/fonts/darker-grotesque-patched-500-normal.woff2 +0 -0
- package/lib/rasterizer/fonts/darker-grotesque-patched-600-normal.woff2 +0 -0
- package/lib/rasterizer/fonts/darker-grotesque-patched-700-normal.woff2 +0 -0
- package/lib/rasterizer/fonts/inter-v3-400-italic.woff2 +0 -0
- package/lib/rasterizer/fonts/inter-v3-400-normal.woff2 +0 -0
- package/lib/rasterizer/fonts/inter-v3-500-normal.woff2 +0 -0
- package/lib/rasterizer/fonts/inter-v3-600-normal.woff2 +0 -0
- package/lib/rasterizer/fonts/inter-v3-700-italic.woff2 +0 -0
- package/lib/rasterizer/fonts/inter-v3-700-normal.woff2 +0 -0
- package/lib/rasterizer/fonts/inter-v3-meta.json +6 -0
- package/lib/rasterizer/render-report-lib.mjs +239 -0
- package/lib/rasterizer/render-report.mjs +25 -0
- package/lib/rasterizer/svg-builder.mjs +1328 -0
- package/lib/rasterizer/test-render.mjs +57 -0
- package/lib/slides/api.mjs +2100 -0
- package/lib/slides/blank-template.deck +0 -0
- package/lib/slides/template-deck.mjs +671 -0
- package/manifest.json +21 -0
- package/mcp-server.mjs +541 -0
- package/package.json +74 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* list-overrides — List all editable override keys per symbol.
|
|
3
|
+
*
|
|
4
|
+
* Usage: node cli.mjs list-overrides <file.deck> [--symbol NAME|ID]
|
|
5
|
+
*/
|
|
6
|
+
import { FigDeck } from '../lib/core/fig-deck.mjs';
|
|
7
|
+
import { nid } from '../lib/core/node-helpers.mjs';
|
|
8
|
+
|
|
9
|
+
export async function run(args, flags) {
|
|
10
|
+
const file = args[0];
|
|
11
|
+
if (!file) { console.error('Usage: list-overrides <file.deck>'); process.exit(1); }
|
|
12
|
+
|
|
13
|
+
const filterSym = flags.symbol || null;
|
|
14
|
+
|
|
15
|
+
const deck = file.endsWith('.fig')
|
|
16
|
+
? FigDeck.fromFigFile(file)
|
|
17
|
+
: await FigDeck.fromDeckFile(file);
|
|
18
|
+
|
|
19
|
+
const symbols = deck.getSymbols();
|
|
20
|
+
|
|
21
|
+
for (const sym of symbols) {
|
|
22
|
+
const symId = nid(sym);
|
|
23
|
+
const symName = sym.name || '(unnamed)';
|
|
24
|
+
|
|
25
|
+
// Filter
|
|
26
|
+
if (filterSym && symName !== filterSym && symId !== filterSym) continue;
|
|
27
|
+
|
|
28
|
+
console.log(`\nSYMBOL "${symName}" (${symId})`);
|
|
29
|
+
console.log('─'.repeat(60));
|
|
30
|
+
|
|
31
|
+
// Walk children and find nodes with overrideKey
|
|
32
|
+
walkForOverrides(deck, symId, 1);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function walkForOverrides(deck, parentId, depth) {
|
|
37
|
+
const children = deck.getChildren(parentId);
|
|
38
|
+
for (const child of children) {
|
|
39
|
+
const id = nid(child);
|
|
40
|
+
const ok = child.overrideKey;
|
|
41
|
+
|
|
42
|
+
if (ok) {
|
|
43
|
+
const indent = ' '.repeat(depth);
|
|
44
|
+
const keyStr = `${ok.sessionID}:${ok.localID}`;
|
|
45
|
+
const type = child.type || '?';
|
|
46
|
+
const name = child.name || '';
|
|
47
|
+
|
|
48
|
+
let detail = '';
|
|
49
|
+
if (type === 'TEXT' && child.textData?.characters) {
|
|
50
|
+
const text = child.textData.characters;
|
|
51
|
+
const preview = text.length > 50 ? text.substring(0, 47) + '...' : text;
|
|
52
|
+
detail = ` → ${JSON.stringify(preview)}`;
|
|
53
|
+
} else if (type === 'ROUNDED_RECTANGLE' || type === 'RECTANGLE') {
|
|
54
|
+
const hasFill = child.fillPaints?.some(p => p.type === 'IMAGE');
|
|
55
|
+
detail = hasFill ? ' [IMAGE PLACEHOLDER]' : '';
|
|
56
|
+
} else if (type === 'INSTANCE') {
|
|
57
|
+
const sid = child.symbolData?.symbolID;
|
|
58
|
+
detail = sid ? ` sym=${sid.sessionID}:${sid.localID}` : '';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.log(`${indent}${keyStr} ${type} "${name}"${detail}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
walkForOverrides(deck, id, depth + 1);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* list-text — List all text content in the deck.
|
|
3
|
+
*
|
|
4
|
+
* Usage: node cli.mjs list-text <file.deck>
|
|
5
|
+
*/
|
|
6
|
+
import { FigDeck } from '../lib/core/fig-deck.mjs';
|
|
7
|
+
import { nid } from '../lib/core/node-helpers.mjs';
|
|
8
|
+
import { hashToHex } from '../lib/core/image-helpers.mjs';
|
|
9
|
+
|
|
10
|
+
export async function run(args) {
|
|
11
|
+
const file = args[0];
|
|
12
|
+
if (!file) { console.error('Usage: list-text <file.deck>'); process.exit(1); }
|
|
13
|
+
|
|
14
|
+
const deck = file.endsWith('.fig')
|
|
15
|
+
? FigDeck.fromFigFile(file)
|
|
16
|
+
: await FigDeck.fromDeckFile(file);
|
|
17
|
+
|
|
18
|
+
// Direct text nodes
|
|
19
|
+
console.log('=== Direct text nodes ===\n');
|
|
20
|
+
for (const node of deck.message.nodeChanges) {
|
|
21
|
+
if (node.type === 'TEXT' && node.textData?.characters) {
|
|
22
|
+
const text = node.textData.characters;
|
|
23
|
+
const preview = text.length > 80 ? text.substring(0, 77) + '...' : text;
|
|
24
|
+
console.log(`[${nid(node)}] "${node.name || ''}" → ${JSON.stringify(preview)}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Override text per slide
|
|
29
|
+
console.log('\n=== Override text (per slide instance) ===\n');
|
|
30
|
+
const slides = deck.getActiveSlides();
|
|
31
|
+
|
|
32
|
+
for (const slide of slides) {
|
|
33
|
+
const inst = deck.getSlideInstance(nid(slide));
|
|
34
|
+
if (!inst) continue;
|
|
35
|
+
|
|
36
|
+
const symId = inst.symbolData?.symbolID;
|
|
37
|
+
const symStr = symId ? `${symId.sessionID}:${symId.localID}` : '?';
|
|
38
|
+
console.log(`SLIDE "${slide.name || nid(slide)}" → INSTANCE (${nid(inst)}) sym=${symStr}`);
|
|
39
|
+
|
|
40
|
+
const overrides = inst.symbolData?.symbolOverrides || [];
|
|
41
|
+
for (const ov of overrides) {
|
|
42
|
+
const path = (ov.guidPath?.guids || [])
|
|
43
|
+
.map(g => `${g.sessionID}:${g.localID}`).join(' → ');
|
|
44
|
+
|
|
45
|
+
if (ov.textData?.characters) {
|
|
46
|
+
const text = ov.textData.characters;
|
|
47
|
+
const preview = text.length > 80 ? text.substring(0, 77) + '...' : text;
|
|
48
|
+
console.log(` ${path} TEXT: ${JSON.stringify(preview)}`);
|
|
49
|
+
}
|
|
50
|
+
if (ov.fillPaints?.length) {
|
|
51
|
+
const paint = ov.fillPaints[0];
|
|
52
|
+
if (paint.type === 'IMAGE' && paint.image?.hash) {
|
|
53
|
+
const hex = hashToHex(paint.image.hash);
|
|
54
|
+
console.log(` ${path} IMAGE: ${hex} (${paint.originalImageWidth}×${paint.originalImageHeight})`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
console.log('');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* remove-slide — Mark slides as REMOVED.
|
|
3
|
+
*
|
|
4
|
+
* Usage: node cli.mjs remove-slide <file.deck> -o <output.deck> --slide <id|name> [--slide ...]
|
|
5
|
+
*/
|
|
6
|
+
import { FigDeck } from '../lib/core/fig-deck.mjs';
|
|
7
|
+
import { nid, removeNode } from '../lib/core/node-helpers.mjs';
|
|
8
|
+
|
|
9
|
+
export async function run(args, flags) {
|
|
10
|
+
const file = args[0];
|
|
11
|
+
const outPath = flags.o || flags.output;
|
|
12
|
+
const slideRefs = Array.isArray(flags.slide) ? flags.slide : (flags.slide ? [flags.slide] : []);
|
|
13
|
+
|
|
14
|
+
if (!file || !outPath || slideRefs.length === 0) {
|
|
15
|
+
console.error('Usage: remove-slide <file.deck> -o <out.deck> --slide <id|name> [--slide ...]');
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const deck = await FigDeck.fromDeckFile(file);
|
|
20
|
+
|
|
21
|
+
let removed = 0;
|
|
22
|
+
for (const ref of slideRefs) {
|
|
23
|
+
const slide = findSlide(deck, ref);
|
|
24
|
+
if (!slide) { console.error(`Slide not found: ${ref}`); continue; }
|
|
25
|
+
|
|
26
|
+
removeNode(slide);
|
|
27
|
+
console.log(` REMOVED slide "${slide.name || ''}" (${nid(slide)})`);
|
|
28
|
+
|
|
29
|
+
// Also remove child instances
|
|
30
|
+
for (const child of deck.getChildren(nid(slide))) {
|
|
31
|
+
removeNode(child);
|
|
32
|
+
console.log(` REMOVED child ${child.type} (${nid(child)})`);
|
|
33
|
+
}
|
|
34
|
+
removed++;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
console.log(`\nRemoved ${removed} slide(s)`);
|
|
38
|
+
|
|
39
|
+
const bytes = await deck.saveDeck(outPath);
|
|
40
|
+
console.log(`Saved: ${outPath} (${bytes} bytes)`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function findSlide(deck, ref) {
|
|
44
|
+
const byId = deck.getNode(ref);
|
|
45
|
+
if (byId?.type === 'SLIDE') return byId;
|
|
46
|
+
return deck.getActiveSlides().find(s => s.name === ref);
|
|
47
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* roundtrip — Decode and re-encode a deck with zero changes (pipeline validation).
|
|
3
|
+
*
|
|
4
|
+
* Usage: node cli.mjs roundtrip <file.deck> -o <output.deck>
|
|
5
|
+
*/
|
|
6
|
+
import { FigDeck } from '../lib/core/fig-deck.mjs';
|
|
7
|
+
|
|
8
|
+
export async function run(args, flags) {
|
|
9
|
+
const file = args[0];
|
|
10
|
+
const outPath = flags.o || flags.output;
|
|
11
|
+
|
|
12
|
+
if (!file || !outPath) {
|
|
13
|
+
console.error('Usage: roundtrip <file.deck> -o <output.deck>');
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
console.log(`Reading: ${file}`);
|
|
18
|
+
const deck = await FigDeck.fromDeckFile(file);
|
|
19
|
+
|
|
20
|
+
const slides = deck.getSlides();
|
|
21
|
+
const active = deck.getActiveSlides();
|
|
22
|
+
const instances = deck.getInstances();
|
|
23
|
+
const symbols = deck.getSymbols();
|
|
24
|
+
|
|
25
|
+
console.log(` Nodes: ${deck.message.nodeChanges.length}`);
|
|
26
|
+
console.log(` Slides: ${active.length} active / ${slides.length} total`);
|
|
27
|
+
console.log(` Instances: ${instances.length}`);
|
|
28
|
+
console.log(` Symbols: ${symbols.length}`);
|
|
29
|
+
console.log(` Blobs: ${deck.message.blobs?.length || 0}`);
|
|
30
|
+
console.log(` Chunks: ${deck.rawFiles.length}`);
|
|
31
|
+
if (deck.deckMeta) console.log(` Deck name: ${deck.deckMeta.file_name || '(unknown)'}`);
|
|
32
|
+
|
|
33
|
+
console.log(`\nEncoding...`);
|
|
34
|
+
const bytes = await deck.saveDeck(outPath);
|
|
35
|
+
console.log(`Saved: ${outPath} (${bytes} bytes)`);
|
|
36
|
+
console.log(`\nRoundtrip complete. Open in Figma to verify.`);
|
|
37
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* update-text — Apply text overrides to an instance on a slide.
|
|
3
|
+
*
|
|
4
|
+
* Usage: node cli.mjs update-text <file.deck> -o <output.deck> --slide <id|name> --set key=value [--set key=value ...]
|
|
5
|
+
*/
|
|
6
|
+
import { FigDeck } from '../lib/core/fig-deck.mjs';
|
|
7
|
+
import { nid, parseId } from '../lib/core/node-helpers.mjs';
|
|
8
|
+
|
|
9
|
+
export async function run(args, flags) {
|
|
10
|
+
const file = args[0];
|
|
11
|
+
const outPath = flags.o || flags.output;
|
|
12
|
+
const slideRef = flags.slide;
|
|
13
|
+
const sets = Array.isArray(flags.set) ? flags.set : (flags.set ? [flags.set] : []);
|
|
14
|
+
|
|
15
|
+
if (!file || !outPath || !slideRef || sets.length === 0) {
|
|
16
|
+
console.error('Usage: update-text <file.deck> -o <out.deck> --slide <id|name> --set key=value [--set ...]');
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const deck = await FigDeck.fromDeckFile(file);
|
|
21
|
+
|
|
22
|
+
// Find slide by ID or name
|
|
23
|
+
const slide = findSlide(deck, slideRef);
|
|
24
|
+
if (!slide) { console.error(`Slide not found: ${slideRef}`); process.exit(1); }
|
|
25
|
+
|
|
26
|
+
const inst = deck.getSlideInstance(nid(slide));
|
|
27
|
+
if (!inst) { console.error(`No instance found on slide ${nid(slide)}`); process.exit(1); }
|
|
28
|
+
|
|
29
|
+
// Ensure symbolOverrides exists
|
|
30
|
+
if (!inst.symbolData) inst.symbolData = {};
|
|
31
|
+
if (!inst.symbolData.symbolOverrides) inst.symbolData.symbolOverrides = [];
|
|
32
|
+
|
|
33
|
+
let updated = 0;
|
|
34
|
+
for (const pair of sets) {
|
|
35
|
+
const eqIdx = pair.indexOf('=');
|
|
36
|
+
if (eqIdx < 0) { console.error(`Invalid --set format: ${pair}`); continue; }
|
|
37
|
+
const keyStr = pair.substring(0, eqIdx);
|
|
38
|
+
let value = pair.substring(eqIdx + 1);
|
|
39
|
+
|
|
40
|
+
// Empty string → space (prevents Figma crash)
|
|
41
|
+
if (value === '') value = ' ';
|
|
42
|
+
|
|
43
|
+
const key = parseId(keyStr);
|
|
44
|
+
const overrides = inst.symbolData.symbolOverrides;
|
|
45
|
+
|
|
46
|
+
// Find existing override for this key
|
|
47
|
+
const existing = overrides.find(o =>
|
|
48
|
+
o.guidPath?.guids?.length === 1 &&
|
|
49
|
+
o.guidPath.guids[0].sessionID === key.sessionID &&
|
|
50
|
+
o.guidPath.guids[0].localID === key.localID &&
|
|
51
|
+
o.textData
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
if (existing) {
|
|
55
|
+
existing.textData.characters = value;
|
|
56
|
+
} else {
|
|
57
|
+
overrides.push({
|
|
58
|
+
guidPath: { guids: [key] },
|
|
59
|
+
textData: { characters: value },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
updated++;
|
|
63
|
+
console.log(` ${keyStr} → ${JSON.stringify(value.substring(0, 60))}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.log(`Updated ${updated} text override(s) on slide "${slide.name || nid(slide)}"`);
|
|
67
|
+
|
|
68
|
+
const bytes = await deck.saveDeck(outPath);
|
|
69
|
+
console.log(`Saved: ${outPath} (${bytes} bytes)`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function findSlide(deck, ref) {
|
|
73
|
+
// Try as ID first
|
|
74
|
+
const byId = deck.getNode(ref);
|
|
75
|
+
if (byId?.type === 'SLIDE') return byId;
|
|
76
|
+
|
|
77
|
+
// Try as name
|
|
78
|
+
return deck.getActiveSlides().find(s => s.name === ref);
|
|
79
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Uint8Array-safe deep clone.
|
|
3
|
+
* JSON.parse(JSON.stringify()) corrupts Uint8Array → plain objects.
|
|
4
|
+
*/
|
|
5
|
+
export function deepClone(obj) {
|
|
6
|
+
if (obj === null || typeof obj !== 'object') return obj;
|
|
7
|
+
if (obj instanceof Uint8Array) return obj.slice();
|
|
8
|
+
if (obj instanceof ArrayBuffer) return obj.slice(0);
|
|
9
|
+
if (obj instanceof Date) return new Date(obj);
|
|
10
|
+
if (Array.isArray(obj)) return obj.map(deepClone);
|
|
11
|
+
const out = {};
|
|
12
|
+
for (const key of Object.keys(obj)) {
|
|
13
|
+
out[key] = deepClone(obj[key]);
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FigDeck — Core class for reading, modifying, and writing Figma .deck/.fig files.
|
|
3
|
+
*
|
|
4
|
+
* FORMAT RULES (hard-won):
|
|
5
|
+
* - .deck = ZIP containing canvas.fig, thumbnail.png, meta.json, images/
|
|
6
|
+
* - canvas.fig = prelude ("fig-deck" or "fig-kiwi") + version (uint32 LE)
|
|
7
|
+
* + length-prefixed chunks
|
|
8
|
+
* - Chunk 0 = kiwi schema (deflateRaw compressed)
|
|
9
|
+
* - Chunk 1 = message data (MUST be zstd compressed — Figma rejects deflateRaw)
|
|
10
|
+
* - Chunk 2+ = optional additional data (pass through as-is)
|
|
11
|
+
*/
|
|
12
|
+
import { decodeBinarySchema, compileSchema, encodeBinarySchema } from 'kiwi-schema';
|
|
13
|
+
import { decompress } from 'fzstd';
|
|
14
|
+
import { inflateRaw, deflateRaw } from 'pako';
|
|
15
|
+
import { ZstdCodec } from 'zstd-codec';
|
|
16
|
+
import archiver from 'archiver';
|
|
17
|
+
import { readFileSync, createWriteStream, existsSync, mkdtempSync } from 'fs';
|
|
18
|
+
import { execSync } from 'child_process';
|
|
19
|
+
import { join, resolve } from 'path';
|
|
20
|
+
import { tmpdir } from 'os';
|
|
21
|
+
import { nid } from './node-helpers.mjs';
|
|
22
|
+
|
|
23
|
+
export class FigDeck {
|
|
24
|
+
constructor() {
|
|
25
|
+
this.header = null; // { prelude, version }
|
|
26
|
+
this.schema = null; // decoded kiwi binary schema
|
|
27
|
+
this.compiledSchema = null;
|
|
28
|
+
this.message = null; // decoded message { nodeChanges, blobs, ... }
|
|
29
|
+
this.rawFiles = []; // original compressed chunks (for passthrough)
|
|
30
|
+
this.nodeMap = new Map(); // "s:l" → node
|
|
31
|
+
this.childrenMap = new Map(); // "s:l" → [child nodes]
|
|
32
|
+
this.deckMeta = null; // parsed meta.json (when loaded from .deck)
|
|
33
|
+
this.deckThumbnail = null; // raw thumbnail PNG bytes
|
|
34
|
+
this.imagesDir = null; // path to extracted images directory
|
|
35
|
+
this._tempDir = null; // temp dir for deck extraction
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Load from a .deck file (ZIP archive).
|
|
40
|
+
*/
|
|
41
|
+
static async fromDeckFile(deckPath) {
|
|
42
|
+
const deck = new FigDeck();
|
|
43
|
+
const absPath = resolve(deckPath);
|
|
44
|
+
|
|
45
|
+
// Extract to temp dir
|
|
46
|
+
const tmp = mkdtempSync(join(tmpdir(), 'openfig_'));
|
|
47
|
+
execSync(`unzip -o "${absPath}" -d "${tmp}"`, { stdio: 'pipe' });
|
|
48
|
+
deck._tempDir = tmp;
|
|
49
|
+
|
|
50
|
+
// Read canvas.fig
|
|
51
|
+
const figPath = join(tmp, 'canvas.fig');
|
|
52
|
+
if (!existsSync(figPath)) {
|
|
53
|
+
throw new Error('No canvas.fig found in deck archive');
|
|
54
|
+
}
|
|
55
|
+
deck._parseFig(readFileSync(figPath));
|
|
56
|
+
|
|
57
|
+
// Read meta.json
|
|
58
|
+
const metaPath = join(tmp, 'meta.json');
|
|
59
|
+
if (existsSync(metaPath)) {
|
|
60
|
+
deck.deckMeta = JSON.parse(readFileSync(metaPath, 'utf8'));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Read thumbnail
|
|
64
|
+
const thumbPath = join(tmp, 'thumbnail.png');
|
|
65
|
+
if (existsSync(thumbPath)) {
|
|
66
|
+
deck.deckThumbnail = readFileSync(thumbPath);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Record images dir
|
|
70
|
+
const imgDir = join(tmp, 'images');
|
|
71
|
+
if (existsSync(imgDir)) {
|
|
72
|
+
deck.imagesDir = imgDir;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return deck;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Load from a raw .fig file.
|
|
80
|
+
*/
|
|
81
|
+
static fromFigFile(figPath) {
|
|
82
|
+
const deck = new FigDeck();
|
|
83
|
+
deck._parseFig(readFileSync(resolve(figPath)));
|
|
84
|
+
return deck;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parse a canvas.fig buffer.
|
|
89
|
+
* Format: prelude (8 bytes ASCII) + version (uint32 LE) + N×(length uint32 LE + chunk bytes)
|
|
90
|
+
* Known preludes: "fig-kiwi", "fig-deck", "fig-jam."
|
|
91
|
+
*/
|
|
92
|
+
_parseFig(buf) {
|
|
93
|
+
const data = new Uint8Array(buf.buffer ?? buf);
|
|
94
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
95
|
+
|
|
96
|
+
// Read 8-byte prelude
|
|
97
|
+
const prelude = String.fromCharCode(...data.subarray(0, 8));
|
|
98
|
+
const version = view.getUint32(8, true);
|
|
99
|
+
this.header = { prelude, version };
|
|
100
|
+
|
|
101
|
+
// Read length-prefixed chunks
|
|
102
|
+
const files = [];
|
|
103
|
+
let off = 12;
|
|
104
|
+
while (off < data.byteLength) {
|
|
105
|
+
const len = view.getUint32(off, true);
|
|
106
|
+
off += 4;
|
|
107
|
+
files.push(data.subarray(off, off + len));
|
|
108
|
+
off += len;
|
|
109
|
+
}
|
|
110
|
+
this.rawFiles = files;
|
|
111
|
+
|
|
112
|
+
// Chunk 0: schema (always deflateRaw)
|
|
113
|
+
const schemaData = inflateRaw(files[0]);
|
|
114
|
+
this.schema = decodeBinarySchema(schemaData);
|
|
115
|
+
this.compiledSchema = compileSchema(this.schema);
|
|
116
|
+
|
|
117
|
+
// Chunk 1: message (zstd or deflateRaw — auto-detect)
|
|
118
|
+
let msgData;
|
|
119
|
+
if (files[1][0] === 0x28 && files[1][1] === 0xb5 &&
|
|
120
|
+
files[1][2] === 0x2f && files[1][3] === 0xfd) {
|
|
121
|
+
msgData = decompress(files[1]); // zstd
|
|
122
|
+
} else {
|
|
123
|
+
msgData = inflateRaw(files[1]); // deflateRaw fallback
|
|
124
|
+
}
|
|
125
|
+
this.message = this.compiledSchema.decodeMessage(msgData);
|
|
126
|
+
|
|
127
|
+
this.rebuildMaps();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Rebuild nodeMap and childrenMap from message.nodeChanges.
|
|
132
|
+
*/
|
|
133
|
+
rebuildMaps() {
|
|
134
|
+
this.nodeMap.clear();
|
|
135
|
+
this.childrenMap.clear();
|
|
136
|
+
|
|
137
|
+
for (const node of this.message.nodeChanges) {
|
|
138
|
+
const id = nid(node);
|
|
139
|
+
if (id) this.nodeMap.set(id, node);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (const node of this.message.nodeChanges) {
|
|
143
|
+
if (!node.parentIndex?.guid) continue;
|
|
144
|
+
const pid = `${node.parentIndex.guid.sessionID}:${node.parentIndex.guid.localID}`;
|
|
145
|
+
if (!this.childrenMap.has(pid)) this.childrenMap.set(pid, []);
|
|
146
|
+
this.childrenMap.get(pid).push(node);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Get node by string ID "s:l" */
|
|
151
|
+
getNode(id) {
|
|
152
|
+
return this.nodeMap.get(id);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Get children of a node by string ID */
|
|
156
|
+
getChildren(id) {
|
|
157
|
+
return this.childrenMap.get(id) || [];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Get all SLIDE nodes */
|
|
161
|
+
getSlides() {
|
|
162
|
+
return this.message.nodeChanges.filter(n => n.type === 'SLIDE');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Get only active (non-REMOVED) slides */
|
|
166
|
+
getActiveSlides() {
|
|
167
|
+
return this.getSlides().filter(n => n.phase !== 'REMOVED');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Get a single active slide by 1-based index. Slide 1 is the first slide. */
|
|
171
|
+
getSlide(n) {
|
|
172
|
+
const slides = this.getActiveSlides();
|
|
173
|
+
if (n < 1 || n > slides.length) {
|
|
174
|
+
throw new RangeError(`Slide ${n} out of range (1–${slides.length})`);
|
|
175
|
+
}
|
|
176
|
+
return slides[n - 1];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Get user-facing CANVAS nodes (pages), sorted by position. Excludes Figma's internal canvas. */
|
|
180
|
+
getPages() {
|
|
181
|
+
return this.message.nodeChanges
|
|
182
|
+
.filter(n => n.type === 'CANVAS' && n.phase !== 'REMOVED' && n.name !== 'Internal Only Canvas')
|
|
183
|
+
.sort((a, b) => (a.parentIndex?.position ?? '').localeCompare(b.parentIndex?.position ?? ''));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Get a single page by 1-based index. Page 1 is the first page. */
|
|
187
|
+
getPage(n) {
|
|
188
|
+
const pages = this.getPages();
|
|
189
|
+
if (n < 1 || n > pages.length) {
|
|
190
|
+
throw new RangeError(`Page ${n} out of range (1–${pages.length})`);
|
|
191
|
+
}
|
|
192
|
+
return pages[n - 1];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Get all INSTANCE nodes */
|
|
196
|
+
getInstances() {
|
|
197
|
+
return this.message.nodeChanges.filter(n => n.type === 'INSTANCE');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Get all SYMBOL nodes */
|
|
201
|
+
getSymbols() {
|
|
202
|
+
return this.message.nodeChanges.filter(n => n.type === 'SYMBOL');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Find the INSTANCE child of a SLIDE */
|
|
206
|
+
getSlideInstance(slideId) {
|
|
207
|
+
const children = this.getChildren(slideId);
|
|
208
|
+
return children.find(c => c.type === 'INSTANCE');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Highest localID in use (for generating new IDs) */
|
|
212
|
+
maxLocalID() {
|
|
213
|
+
let max = 0;
|
|
214
|
+
for (const node of this.message.nodeChanges) {
|
|
215
|
+
if (node.guid?.localID > max) max = node.guid.localID;
|
|
216
|
+
}
|
|
217
|
+
return max;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* DFS walk from a root node.
|
|
222
|
+
* @param {string} rootId - "s:l" format
|
|
223
|
+
* @param {Function} visitor - (node, depth) => void
|
|
224
|
+
*/
|
|
225
|
+
walkTree(rootId, visitor, depth = 0) {
|
|
226
|
+
const node = this.getNode(rootId);
|
|
227
|
+
if (!node) return;
|
|
228
|
+
visitor(node, depth);
|
|
229
|
+
for (const child of this.getChildren(rootId)) {
|
|
230
|
+
this.walkTree(nid(child), visitor, depth + 1);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Encode message to canvas.fig binary.
|
|
236
|
+
* Returns a Promise<Uint8Array> because zstd-codec uses callbacks.
|
|
237
|
+
*/
|
|
238
|
+
encodeFig() {
|
|
239
|
+
return new Promise((resolve, reject) => {
|
|
240
|
+
ZstdCodec.run(zstd => {
|
|
241
|
+
try {
|
|
242
|
+
const z = new zstd.Simple();
|
|
243
|
+
|
|
244
|
+
const encodedMsg = this.compiledSchema.encodeMessage(this.message);
|
|
245
|
+
const compSchema = deflateRaw(encodeBinarySchema(this.schema));
|
|
246
|
+
const compMsg = z.compress(encodedMsg, 3);
|
|
247
|
+
|
|
248
|
+
const prelude = this.header.prelude;
|
|
249
|
+
const chunks = [compSchema, compMsg];
|
|
250
|
+
// Pass through any additional chunks (chunk 2+)
|
|
251
|
+
for (let i = 2; i < this.rawFiles.length; i++) {
|
|
252
|
+
chunks.push(this.rawFiles[i]);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const headerSize = prelude.length + 4;
|
|
256
|
+
const totalSize = chunks.reduce((sz, c) => sz + 4 + c.byteLength, headerSize);
|
|
257
|
+
const buf = new Uint8Array(totalSize);
|
|
258
|
+
const view = new DataView(buf.buffer);
|
|
259
|
+
const enc = new TextEncoder();
|
|
260
|
+
|
|
261
|
+
let off = 0;
|
|
262
|
+
off = enc.encodeInto(prelude, buf).written;
|
|
263
|
+
view.setUint32(off, this.header.version, true);
|
|
264
|
+
off += 4;
|
|
265
|
+
|
|
266
|
+
for (const chunk of chunks) {
|
|
267
|
+
view.setUint32(off, chunk.byteLength, true);
|
|
268
|
+
off += 4;
|
|
269
|
+
buf.set(chunk, off);
|
|
270
|
+
off += chunk.byteLength;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
resolve(buf);
|
|
274
|
+
} catch (e) {
|
|
275
|
+
reject(e);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Save as a .deck (ZIP archive).
|
|
283
|
+
* @param {string} outPath - Output file path
|
|
284
|
+
* @param {object} opts - { imagesDir, thumbnail, meta }
|
|
285
|
+
*/
|
|
286
|
+
async saveDeck(outPath, opts = {}) {
|
|
287
|
+
const figBuf = await this.encodeFig();
|
|
288
|
+
const absOut = resolve(outPath);
|
|
289
|
+
|
|
290
|
+
return new Promise((resolveP, reject) => {
|
|
291
|
+
const output = createWriteStream(absOut);
|
|
292
|
+
const archive = archiver('zip', { store: true });
|
|
293
|
+
|
|
294
|
+
archive.on('error', reject);
|
|
295
|
+
output.on('close', () => resolveP(archive.pointer()));
|
|
296
|
+
|
|
297
|
+
archive.pipe(output);
|
|
298
|
+
|
|
299
|
+
// canvas.fig
|
|
300
|
+
archive.append(Buffer.from(figBuf), { name: 'canvas.fig' });
|
|
301
|
+
|
|
302
|
+
// thumbnail.png
|
|
303
|
+
const thumb = opts.thumbnail || this.deckThumbnail;
|
|
304
|
+
if (thumb) {
|
|
305
|
+
archive.append(Buffer.from(thumb), { name: 'thumbnail.png' });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// meta.json
|
|
309
|
+
const meta = opts.meta || this.deckMeta;
|
|
310
|
+
if (meta) {
|
|
311
|
+
archive.append(JSON.stringify(meta), { name: 'meta.json' });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// images/
|
|
315
|
+
const imgDir = opts.imagesDir || this.imagesDir;
|
|
316
|
+
if (imgDir && existsSync(imgDir)) {
|
|
317
|
+
archive.directory(imgDir, 'images');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
archive.finalize();
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Save just the canvas.fig binary.
|
|
326
|
+
*/
|
|
327
|
+
async saveFig(outPath) {
|
|
328
|
+
const buf = await this.encodeFig();
|
|
329
|
+
const { writeFileSync } = await import('fs');
|
|
330
|
+
writeFileSync(resolve(outPath), buf);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image override utilities for Figma .deck files.
|
|
3
|
+
*
|
|
4
|
+
* CRITICAL RULES:
|
|
5
|
+
* - styleIdForFill with sentinel GUID (all 0xFFFFFFFF) is REQUIRED
|
|
6
|
+
* - imageThumbnail with real thumbnail hash (~320px PNG) is REQUIRED
|
|
7
|
+
* - thumbHash must be new Uint8Array(0), NOT {}
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Convert 40-char hex SHA-1 string to Uint8Array(20). */
|
|
11
|
+
export function hexToHash(hex) {
|
|
12
|
+
const arr = new Uint8Array(20);
|
|
13
|
+
for (let i = 0; i < 20; i++) {
|
|
14
|
+
arr[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
|
|
15
|
+
}
|
|
16
|
+
return arr;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Convert Uint8Array(20) hash back to 40-char hex string. */
|
|
20
|
+
export function hashToHex(arr) {
|
|
21
|
+
return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build a complete image fill override for symbolOverrides.
|
|
26
|
+
*
|
|
27
|
+
* @param {object} key - Override key { sessionID, localID }
|
|
28
|
+
* @param {string} hash - 40-char hex SHA-1 of the full image
|
|
29
|
+
* @param {string} thumbHash - 40-char hex SHA-1 of the thumbnail image
|
|
30
|
+
* @param {number} width - Original image width
|
|
31
|
+
* @param {number} height - Original image height
|
|
32
|
+
*/
|
|
33
|
+
export function imageOv(key, hash, thumbHash, width, height) {
|
|
34
|
+
return {
|
|
35
|
+
styleIdForFill: { guid: { sessionID: 4294967295, localID: 4294967295 } },
|
|
36
|
+
guidPath: { guids: [key] },
|
|
37
|
+
fillPaints: [{
|
|
38
|
+
type: 'IMAGE',
|
|
39
|
+
opacity: 1,
|
|
40
|
+
visible: true,
|
|
41
|
+
blendMode: 'NORMAL',
|
|
42
|
+
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
|
|
43
|
+
image: { hash: hexToHash(hash), name: hash },
|
|
44
|
+
imageThumbnail: { hash: hexToHash(thumbHash), name: hash },
|
|
45
|
+
animationFrame: 0,
|
|
46
|
+
imageScaleMode: 'FILL',
|
|
47
|
+
imageShouldColorManage: false,
|
|
48
|
+
rotation: 0,
|
|
49
|
+
scale: 0.5,
|
|
50
|
+
originalImageWidth: width,
|
|
51
|
+
originalImageHeight: height,
|
|
52
|
+
thumbHash: new Uint8Array(0),
|
|
53
|
+
altText: '',
|
|
54
|
+
}],
|
|
55
|
+
};
|
|
56
|
+
}
|