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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +95 -0
  3. package/bin/cli.mjs +111 -0
  4. package/bin/commands/clone-slide.mjs +153 -0
  5. package/bin/commands/export.mjs +83 -0
  6. package/bin/commands/insert-image.mjs +90 -0
  7. package/bin/commands/inspect.mjs +91 -0
  8. package/bin/commands/list-overrides.mjs +66 -0
  9. package/bin/commands/list-text.mjs +60 -0
  10. package/bin/commands/remove-slide.mjs +47 -0
  11. package/bin/commands/roundtrip.mjs +37 -0
  12. package/bin/commands/update-text.mjs +79 -0
  13. package/lib/core/deep-clone.mjs +16 -0
  14. package/lib/core/fig-deck.mjs +332 -0
  15. package/lib/core/image-helpers.mjs +56 -0
  16. package/lib/core/image-utils.mjs +29 -0
  17. package/lib/core/node-helpers.mjs +49 -0
  18. package/lib/rasterizer/deck-rasterizer.mjs +233 -0
  19. package/lib/rasterizer/download-font.mjs +57 -0
  20. package/lib/rasterizer/font-resolver.mjs +602 -0
  21. package/lib/rasterizer/fonts/DarkerGrotesque-400.ttf +0 -0
  22. package/lib/rasterizer/fonts/DarkerGrotesque-500.ttf +0 -0
  23. package/lib/rasterizer/fonts/DarkerGrotesque-600.ttf +0 -0
  24. package/lib/rasterizer/fonts/Inter-Regular.ttf +0 -0
  25. package/lib/rasterizer/fonts/Inter-Variable.ttf +0 -0
  26. package/lib/rasterizer/fonts/Inter.var.woff2 +0 -0
  27. package/lib/rasterizer/fonts/avenir-next-bold-italic.ttf +0 -0
  28. package/lib/rasterizer/fonts/avenir-next-bold.ttf +0 -0
  29. package/lib/rasterizer/fonts/avenir-next-demibold-italic.ttf +0 -0
  30. package/lib/rasterizer/fonts/avenir-next-demibold.ttf +0 -0
  31. package/lib/rasterizer/fonts/avenir-next-italic.ttf +0 -0
  32. package/lib/rasterizer/fonts/avenir-next-medium-italic.ttf +0 -0
  33. package/lib/rasterizer/fonts/avenir-next-medium.ttf +0 -0
  34. package/lib/rasterizer/fonts/avenir-next-regular.ttf +0 -0
  35. package/lib/rasterizer/fonts/darker-grotesque-patched-400-normal.woff2 +0 -0
  36. package/lib/rasterizer/fonts/darker-grotesque-patched-500-normal.woff2 +0 -0
  37. package/lib/rasterizer/fonts/darker-grotesque-patched-600-normal.woff2 +0 -0
  38. package/lib/rasterizer/fonts/darker-grotesque-patched-700-normal.woff2 +0 -0
  39. package/lib/rasterizer/fonts/inter-v3-400-italic.woff2 +0 -0
  40. package/lib/rasterizer/fonts/inter-v3-400-normal.woff2 +0 -0
  41. package/lib/rasterizer/fonts/inter-v3-500-normal.woff2 +0 -0
  42. package/lib/rasterizer/fonts/inter-v3-600-normal.woff2 +0 -0
  43. package/lib/rasterizer/fonts/inter-v3-700-italic.woff2 +0 -0
  44. package/lib/rasterizer/fonts/inter-v3-700-normal.woff2 +0 -0
  45. package/lib/rasterizer/fonts/inter-v3-meta.json +6 -0
  46. package/lib/rasterizer/render-report-lib.mjs +239 -0
  47. package/lib/rasterizer/render-report.mjs +25 -0
  48. package/lib/rasterizer/svg-builder.mjs +1328 -0
  49. package/lib/rasterizer/test-render.mjs +57 -0
  50. package/lib/slides/api.mjs +2100 -0
  51. package/lib/slides/blank-template.deck +0 -0
  52. package/lib/slides/template-deck.mjs +671 -0
  53. package/manifest.json +21 -0
  54. package/mcp-server.mjs +541 -0
  55. 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
+ }