figmatk 0.0.6

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.
Binary file
@@ -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,307 @@
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, mkdirSync, cpSync } from 'fs';
18
+ import { execSync } from 'child_process';
19
+ import { join, resolve } from 'path';
20
+ import { nid } from './node-helpers.mjs';
21
+
22
+ export class FigDeck {
23
+ constructor() {
24
+ this.header = null; // { prelude, version }
25
+ this.schema = null; // decoded kiwi binary schema
26
+ this.compiledSchema = null;
27
+ this.message = null; // decoded message { nodeChanges, blobs, ... }
28
+ this.rawFiles = []; // original compressed chunks (for passthrough)
29
+ this.nodeMap = new Map(); // "s:l" → node
30
+ this.childrenMap = new Map(); // "s:l" → [child nodes]
31
+ this.deckMeta = null; // parsed meta.json (when loaded from .deck)
32
+ this.deckThumbnail = null; // raw thumbnail PNG bytes
33
+ this.imagesDir = null; // path to extracted images directory
34
+ this._tempDir = null; // temp dir for deck extraction
35
+ }
36
+
37
+ /**
38
+ * Load from a .deck file (ZIP archive).
39
+ */
40
+ static async fromDeckFile(deckPath) {
41
+ const deck = new FigDeck();
42
+ const absPath = resolve(deckPath);
43
+
44
+ // Extract to temp dir
45
+ const tmp = `/tmp/figmatk_${Date.now()}`;
46
+ execSync(`rm -rf ${tmp} && mkdir -p ${tmp}`);
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 all INSTANCE nodes */
171
+ getInstances() {
172
+ return this.message.nodeChanges.filter(n => n.type === 'INSTANCE');
173
+ }
174
+
175
+ /** Get all SYMBOL nodes */
176
+ getSymbols() {
177
+ return this.message.nodeChanges.filter(n => n.type === 'SYMBOL');
178
+ }
179
+
180
+ /** Find the INSTANCE child of a SLIDE */
181
+ getSlideInstance(slideId) {
182
+ const children = this.getChildren(slideId);
183
+ return children.find(c => c.type === 'INSTANCE');
184
+ }
185
+
186
+ /** Highest localID in use (for generating new IDs) */
187
+ maxLocalID() {
188
+ let max = 0;
189
+ for (const node of this.message.nodeChanges) {
190
+ if (node.guid?.localID > max) max = node.guid.localID;
191
+ }
192
+ return max;
193
+ }
194
+
195
+ /**
196
+ * DFS walk from a root node.
197
+ * @param {string} rootId - "s:l" format
198
+ * @param {Function} visitor - (node, depth) => void
199
+ */
200
+ walkTree(rootId, visitor, depth = 0) {
201
+ const node = this.getNode(rootId);
202
+ if (!node) return;
203
+ visitor(node, depth);
204
+ for (const child of this.getChildren(rootId)) {
205
+ this.walkTree(nid(child), visitor, depth + 1);
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Encode message to canvas.fig binary.
211
+ * Returns a Promise<Uint8Array> because zstd-codec uses callbacks.
212
+ */
213
+ encodeFig() {
214
+ return new Promise((resolve, reject) => {
215
+ ZstdCodec.run(zstd => {
216
+ try {
217
+ const z = new zstd.Simple();
218
+
219
+ const encodedMsg = this.compiledSchema.encodeMessage(this.message);
220
+ const compSchema = deflateRaw(encodeBinarySchema(this.schema));
221
+ const compMsg = z.compress(encodedMsg, 3);
222
+
223
+ const prelude = this.header.prelude;
224
+ const chunks = [compSchema, compMsg];
225
+ // Pass through any additional chunks (chunk 2+)
226
+ for (let i = 2; i < this.rawFiles.length; i++) {
227
+ chunks.push(this.rawFiles[i]);
228
+ }
229
+
230
+ const headerSize = prelude.length + 4;
231
+ const totalSize = chunks.reduce((sz, c) => sz + 4 + c.byteLength, headerSize);
232
+ const buf = new Uint8Array(totalSize);
233
+ const view = new DataView(buf.buffer);
234
+ const enc = new TextEncoder();
235
+
236
+ let off = 0;
237
+ off = enc.encodeInto(prelude, buf).written;
238
+ view.setUint32(off, this.header.version, true);
239
+ off += 4;
240
+
241
+ for (const chunk of chunks) {
242
+ view.setUint32(off, chunk.byteLength, true);
243
+ off += 4;
244
+ buf.set(chunk, off);
245
+ off += chunk.byteLength;
246
+ }
247
+
248
+ resolve(buf);
249
+ } catch (e) {
250
+ reject(e);
251
+ }
252
+ });
253
+ });
254
+ }
255
+
256
+ /**
257
+ * Save as a .deck (ZIP archive).
258
+ * @param {string} outPath - Output file path
259
+ * @param {object} opts - { imagesDir, thumbnail, meta }
260
+ */
261
+ async saveDeck(outPath, opts = {}) {
262
+ const figBuf = await this.encodeFig();
263
+ const absOut = resolve(outPath);
264
+
265
+ return new Promise((resolveP, reject) => {
266
+ const output = createWriteStream(absOut);
267
+ const archive = archiver('zip', { store: true });
268
+
269
+ archive.on('error', reject);
270
+ output.on('close', () => resolveP(archive.pointer()));
271
+
272
+ archive.pipe(output);
273
+
274
+ // canvas.fig
275
+ archive.append(Buffer.from(figBuf), { name: 'canvas.fig' });
276
+
277
+ // thumbnail.png
278
+ const thumb = opts.thumbnail || this.deckThumbnail;
279
+ if (thumb) {
280
+ archive.append(Buffer.from(thumb), { name: 'thumbnail.png' });
281
+ }
282
+
283
+ // meta.json
284
+ const meta = opts.meta || this.deckMeta;
285
+ if (meta) {
286
+ archive.append(JSON.stringify(meta), { name: 'meta.json' });
287
+ }
288
+
289
+ // images/
290
+ const imgDir = opts.imagesDir || this.imagesDir;
291
+ if (imgDir && existsSync(imgDir)) {
292
+ archive.directory(imgDir, 'images');
293
+ }
294
+
295
+ archive.finalize();
296
+ });
297
+ }
298
+
299
+ /**
300
+ * Save just the canvas.fig binary.
301
+ */
302
+ async saveFig(outPath) {
303
+ const buf = await this.encodeFig();
304
+ const { writeFileSync } = await import('fs');
305
+ writeFileSync(resolve(outPath), buf);
306
+ }
307
+ }
@@ -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
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Cross-platform image utilities using sharp.
3
+ * Replaces macOS-only sips calls.
4
+ */
5
+ import sharp from 'sharp';
6
+ import { writeFileSync } from 'fs';
7
+
8
+ /**
9
+ * Get pixel dimensions of an image.
10
+ * @param {string|Buffer} input - file path or buffer
11
+ * @returns {Promise<{width: number, height: number}>}
12
+ */
13
+ export async function getImageDimensions(input) {
14
+ const meta = await sharp(input).metadata();
15
+ return { width: meta.width ?? 0, height: meta.height ?? 0 };
16
+ }
17
+
18
+ /**
19
+ * Generate a thumbnail (~320px wide) and write to a temp file.
20
+ * @param {string|Buffer} input - file path or buffer
21
+ * @param {string} outPath - destination file path
22
+ * @returns {Promise<void>}
23
+ */
24
+ export async function generateThumbnail(input, outPath) {
25
+ await sharp(input)
26
+ .resize(320, null, { withoutEnlargement: true })
27
+ .png()
28
+ .toFile(outPath);
29
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Node ID formatting, tree walking, override builders.
3
+ */
4
+
5
+ /** Format a node's guid as "sessionID:localID" */
6
+ export function nid(node) {
7
+ if (!node?.guid) return null;
8
+ return `${node.guid.sessionID}:${node.guid.localID}`;
9
+ }
10
+
11
+ /** Parse "57:48" → { sessionID: 57, localID: 48 } */
12
+ export function parseId(str) {
13
+ const [s, l] = str.split(':').map(Number);
14
+ return { sessionID: s, localID: l };
15
+ }
16
+
17
+ /** Shorthand for { sessionID, localID } */
18
+ export function makeGuid(sessionID, localID) {
19
+ return { sessionID, localID };
20
+ }
21
+
22
+ /**
23
+ * Build a text override for symbolOverrides.
24
+ * Empty string is replaced with ' ' (space) — empty crashes Figma.
25
+ */
26
+ export function ov(key, text) {
27
+ const chars = (text === '' || text == null) ? ' ' : text;
28
+ return { guidPath: { guids: [key] }, textData: { characters: chars } };
29
+ }
30
+
31
+ /**
32
+ * Build a nested text override (e.g., quote inside paraGrid).
33
+ * guidPath has 2 guids: [instanceKey, textKey].
34
+ */
35
+ export function nestedOv(instKey, textKey, text) {
36
+ const chars = (text === '' || text == null) ? ' ' : text;
37
+ return { guidPath: { guids: [instKey, textKey] }, textData: { characters: chars } };
38
+ }
39
+
40
+ /** Mark a node as REMOVED (never delete from nodeChanges array). */
41
+ export function removeNode(node) {
42
+ node.phase = 'REMOVED';
43
+ delete node.prototypeInteractions;
44
+ }
45
+
46
+ /** Position character for sibling ordering in parentIndex. */
47
+ export function positionChar(index) {
48
+ return String.fromCharCode(0x21 + index); // '!' = 0, '"' = 1, etc.
49
+ }