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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OpenFig Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,95 @@
1
+ <img src="assets/logo.webp" alt="OpenFig" width="320" />
2
+
3
+ Open tools for Figma files.
4
+
5
+ Parse, inspect, and render `.deck` and `.fig` files without the Figma application — including PNG export.
6
+
7
+ OpenFig is an open-source implementation of the Figma file format that allows developers to inspect, parse, and render Figma files without using the Figma application or API. The primary use case is Figma Slides (`.deck`), with `.fig` design file support also available.
8
+
9
+ ## Figma File Formats
10
+
11
+ Each Figma product has its own native file format. Active development — status may change:
12
+
13
+ | Product | Extension | Status |
14
+ |---------|-----------|--------|
15
+ | Figma Slides | `.deck` | ✅ |
16
+ | Figma Design | `.fig` | ✅ read + PNG render |
17
+ | Figma Jam (whiteboard) | `.jam` | ❌ not yet |
18
+ | Figma Buzz | `.buzz` | ❌ not yet |
19
+ | Figma Sites | `.site` | ❌ not yet |
20
+ | Figma Make | `.make` | ❌ not yet |
21
+
22
+ ## Render Quality
23
+
24
+ OpenFig achieves **≥99% SSIM** (Structural Similarity Index) against Figma reference exports across all test cases. Render fidelity is verified with visual regression tests against real Figma-exported PNGs.
25
+
26
+ | Test suite | Visual results |
27
+ |------------|----------------|
28
+ | `.deck` slides | [render-report-deck.html](https://rcoenen.github.io/OpenFig/test/rasterizer/reports/openfig-render-report-deck.html) |
29
+ | `.fig` design frames | [render-report-fig.html](https://rcoenen.github.io/OpenFig/test/rasterizer/reports/openfig-render-report-fig.html) |
30
+
31
+ ## Why native `.deck`?
32
+
33
+ Figma Slides lets you download presentations as `.deck` files and re-upload them. This is the **native** round-trip format. Exporting to `.pptx` is lossy — vectors get rasterized, fonts fall back to system defaults, layout breaks. By staying in `.deck`, you preserve everything exactly as Figma renders it.
34
+
35
+ OpenFig makes this round-trip programmable. Download a `.deck`, modify it, re-upload. Everything stays native.
36
+
37
+ Plug in Claude Cowork or any coding agent and you have an AI that can read and edit Figma presentations end-to-end — without ever opening the Figma UI.
38
+
39
+ ## Use Cases
40
+
41
+ - **AI agent for presentations** — let an LLM rewrite copy, insert images, and produce a ready-to-upload `.deck`
42
+ - **Batch-produce branded decks** — start from a template, feed in data per client/project, get pixel-perfect slides out
43
+ - **Inspect and audit** — understand the internal structure of any `.deck` file
44
+ - **Automate** text and image placement across dozens of slides in seconds
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ npm install -g openfig
50
+ ```
51
+
52
+ Node 18+. No build step. Pure ESM.
53
+
54
+ ## Quick Start
55
+
56
+ ```bash
57
+ openfig inspect my-presentation.deck # node hierarchy
58
+ openfig list-text my-presentation.deck # all text + images per slide
59
+ openfig list-overrides my-presentation.deck # editable fields per symbol
60
+ ```
61
+
62
+ > Full CLI reference: [docs/cli.md](docs/cli.md)
63
+
64
+ ## Claude Cowork / MCP Integration
65
+
66
+ > Install guide, MCP workflows, and template states: [docs/agentic/claude-cowork.md](docs/agentic/claude-cowork.md)
67
+
68
+ ## Programmatic API
69
+
70
+ ```javascript
71
+ import { Deck } from 'openfig';
72
+
73
+ const deck = await Deck.open('template.deck');
74
+ const slide = deck.slides[0];
75
+ slide.addText('Hello world', { style: 'Title' });
76
+ await deck.save('output.deck');
77
+ ```
78
+
79
+ | Docs | |
80
+ |------|---|
81
+ | MCP / Claude workflows | [docs/mcp.md](docs/mcp.md) |
82
+ | High-level API | [docs/api-spec.md](docs/api-spec.md) |
83
+ | Low-level FigDeck API | [docs/library.md](docs/library.md) |
84
+ | Template workflows | [docs/template-workflows.md](docs/template-workflows.md) |
85
+ | File format internals | [docs/format/](docs/format/) |
86
+
87
+ ## License
88
+
89
+ MIT
90
+
91
+ ## Disclaimer
92
+
93
+ Figma is a trademark of Figma, Inc.
94
+
95
+ OpenFig is an independent open-source project and is not affiliated with, endorsed by, or sponsored by Figma, Inc.
package/bin/cli.mjs ADDED
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * OpenFig — Open-source tools for Figma .deck / .fig files.
4
+ *
5
+ * Usage: openfig <command> [args...]
6
+ *
7
+ * Commands:
8
+ * inspect Show document structure (node hierarchy tree)
9
+ * list-text List all text content in the deck
10
+ * list-overrides List all editable override keys per symbol
11
+ * update-text Apply text overrides to a slide instance
12
+ * insert-image Apply an image fill override to a slide instance
13
+ * clone-slide Duplicate a template slide with new content
14
+ * remove-slide Mark slides as REMOVED
15
+ * roundtrip Decode and re-encode (pipeline validation)
16
+ *
17
+ * Disclaimer:
18
+ * Figma is a trademark of Figma, Inc.
19
+ * OpenFig is an independent open-source project and is not affiliated with,
20
+ * endorsed by, or sponsored by Figma, Inc.
21
+ */
22
+
23
+ const COMMANDS = {
24
+ 'inspect': './commands/inspect.mjs',
25
+ 'list-text': './commands/list-text.mjs',
26
+ 'list-overrides': './commands/list-overrides.mjs',
27
+ 'update-text': './commands/update-text.mjs',
28
+ 'insert-image': './commands/insert-image.mjs',
29
+ 'clone-slide': './commands/clone-slide.mjs',
30
+ 'remove-slide': './commands/remove-slide.mjs',
31
+ 'roundtrip': './commands/roundtrip.mjs',
32
+ 'export': './commands/export.mjs',
33
+ };
34
+
35
+ const arg2 = process.argv[2];
36
+ let command, rawArgs;
37
+
38
+ if (!arg2 || arg2 === '--help' || arg2 === '-h') {
39
+ console.log(`OpenFig — Open-source tools for Figma .deck / .fig files\n`);
40
+ console.log('Usage: openfig <command> [args...]\n');
41
+ console.log('Commands:');
42
+ console.log(' export Export slides as images (PNG/JPG/WEBP)');
43
+ console.log(' inspect Show document structure (node hierarchy tree)');
44
+ console.log(' list-text List all text content in the deck');
45
+ console.log(' list-overrides List editable override keys per symbol');
46
+ console.log(' update-text Apply text overrides to a slide instance');
47
+ console.log(' insert-image Apply image fill override to a slide instance');
48
+ console.log(' clone-slide Duplicate a template slide with new content');
49
+ console.log(' remove-slide Mark slides as REMOVED');
50
+ console.log(' roundtrip Decode and re-encode (pipeline validation)');
51
+ process.exit(0);
52
+ }
53
+
54
+ if (COMMANDS[arg2]) {
55
+ command = arg2;
56
+ rawArgs = process.argv.slice(3);
57
+ } else {
58
+ console.error(`Unknown command: ${arg2}\nRun with --help for available commands.`);
59
+ process.exit(1);
60
+ }
61
+
62
+ // Parse args: positional args + flags (--flag value, --flag=value)
63
+ const positional = [];
64
+ const flags = {};
65
+
66
+ for (let i = 0; i < rawArgs.length; i++) {
67
+ const arg = rawArgs[i];
68
+ if (arg.startsWith('--')) {
69
+ const eqIdx = arg.indexOf('=');
70
+ let key, value;
71
+ if (eqIdx >= 0) {
72
+ key = arg.substring(2, eqIdx);
73
+ value = arg.substring(eqIdx + 1);
74
+ } else {
75
+ key = arg.substring(2);
76
+ // Peek ahead for value (unless next arg is also a flag)
77
+ if (i + 1 < rawArgs.length && !rawArgs[i + 1].startsWith('--')) {
78
+ value = rawArgs[++i];
79
+ } else {
80
+ value = true;
81
+ }
82
+ }
83
+ // Support repeating flags (e.g. --set k=v --set k2=v2)
84
+ if (flags[key] !== undefined) {
85
+ if (!Array.isArray(flags[key])) flags[key] = [flags[key]];
86
+ flags[key].push(value);
87
+ } else {
88
+ flags[key] = value;
89
+ }
90
+ } else if (arg.startsWith('-') && arg.length === 2) {
91
+ // Short flag like -o
92
+ const key = arg.substring(1);
93
+ if (i + 1 < rawArgs.length && !rawArgs[i + 1].startsWith('-')) {
94
+ flags[key] = rawArgs[++i];
95
+ } else {
96
+ flags[key] = true;
97
+ }
98
+ } else {
99
+ positional.push(arg);
100
+ }
101
+ }
102
+
103
+ // Run command
104
+ const mod = await import(COMMANDS[command]);
105
+ try {
106
+ await mod.run(positional, flags);
107
+ } catch (err) {
108
+ console.error(`Error: ${err.message}`);
109
+ if (process.env.DEBUG) console.error(err.stack);
110
+ process.exit(1);
111
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * clone-slide — Duplicate a template slide with new content.
3
+ *
4
+ * Usage: node cli.mjs clone-slide <file.deck> -o <output.deck>
5
+ * --template <slideId|name> --name <newName>
6
+ * [--after <slideId>] [--set key=value ...] [--set-image key=path ...]
7
+ */
8
+ import { FigDeck } from '../lib/core/fig-deck.mjs';
9
+ import { nid, parseId, positionChar } from '../lib/core/node-helpers.mjs';
10
+ import { imageOv } from '../lib/core/image-helpers.mjs';
11
+ import { deepClone } from '../lib/core/deep-clone.mjs';
12
+ import { readFileSync, copyFileSync, existsSync, mkdirSync } from 'fs';
13
+ import { createHash } from 'crypto';
14
+ import { join, resolve } from 'path';
15
+ import { getImageDimensions, generateThumbnail } from '../lib/core/image-utils.mjs';
16
+
17
+ function sha1Hex(buf) {
18
+ return createHash('sha1').update(buf).digest('hex');
19
+ }
20
+
21
+ export async function run(args, flags) {
22
+ const file = args[0];
23
+ const outPath = flags.o || flags.output;
24
+ const templateRef = flags.template;
25
+ const newName = flags.name || 'New Slide';
26
+ const sets = Array.isArray(flags.set) ? flags.set : (flags.set ? [flags.set] : []);
27
+ const setImages = Array.isArray(flags['set-image']) ? flags['set-image'] : (flags['set-image'] ? [flags['set-image']] : []);
28
+
29
+ if (!file || !outPath || !templateRef) {
30
+ console.error('Usage: clone-slide <file.deck> -o <out.deck> --template <id|name> --name <name> [--set key=val ...] [--set-image key=path ...]');
31
+ process.exit(1);
32
+ }
33
+
34
+ const deck = await FigDeck.fromDeckFile(file);
35
+
36
+ // Find template slide
37
+ const tmplSlide = findSlide(deck, templateRef);
38
+ if (!tmplSlide) { console.error(`Template slide not found: ${templateRef}`); process.exit(1); }
39
+
40
+ const tmplInst = deck.getSlideInstance(nid(tmplSlide));
41
+ if (!tmplInst) { console.error(`No instance on template slide`); process.exit(1); }
42
+
43
+ // Find SLIDE_ROW parent
44
+ const slideRowId = tmplSlide.parentIndex?.guid
45
+ ? `${tmplSlide.parentIndex.guid.sessionID}:${tmplSlide.parentIndex.guid.localID}`
46
+ : null;
47
+
48
+ // Generate new IDs
49
+ let nextId = deck.maxLocalID() + 1;
50
+ const slideId = nextId++;
51
+ const instId = nextId++;
52
+
53
+ // Clone slide node
54
+ const newSlide = deepClone(tmplSlide);
55
+ newSlide.guid = { sessionID: 1, localID: slideId };
56
+ newSlide.name = newName;
57
+ newSlide.phase = 'CREATED';
58
+ if (slideRowId) {
59
+ const activeCount = deck.getActiveSlides().length;
60
+ newSlide.parentIndex = {
61
+ guid: parseId(slideRowId),
62
+ position: positionChar(activeCount),
63
+ };
64
+ }
65
+ delete newSlide.prototypeInteractions;
66
+ delete newSlide.slideThumbnailHash;
67
+ delete newSlide.editInfo;
68
+
69
+ // Clone instance
70
+ const newInst = deepClone(tmplInst);
71
+ newInst.guid = { sessionID: 1, localID: instId };
72
+ newInst.name = newName;
73
+ newInst.phase = 'CREATED';
74
+ newInst.parentIndex = { guid: { sessionID: 1, localID: slideId }, position: '!' };
75
+ newInst.symbolData = {
76
+ symbolID: deepClone(tmplInst.symbolData?.symbolID),
77
+ symbolOverrides: [],
78
+ uniformScaleFactor: 1,
79
+ };
80
+ delete newInst.derivedSymbolData;
81
+ delete newInst.derivedSymbolDataLayoutVersion;
82
+ delete newInst.editInfo;
83
+
84
+ // Apply text overrides
85
+ for (const pair of sets) {
86
+ const eqIdx = pair.indexOf('=');
87
+ if (eqIdx < 0) continue;
88
+ const key = parseId(pair.substring(0, eqIdx));
89
+ let value = pair.substring(eqIdx + 1);
90
+ if (value === '') value = ' ';
91
+ newInst.symbolData.symbolOverrides.push({
92
+ guidPath: { guids: [key] },
93
+ textData: { characters: value },
94
+ });
95
+ }
96
+
97
+ // Apply image overrides
98
+ for (const pair of setImages) {
99
+ const eqIdx = pair.indexOf('=');
100
+ if (eqIdx < 0) continue;
101
+ const key = parseId(pair.substring(0, eqIdx));
102
+ const imgPath = resolve(pair.substring(eqIdx + 1));
103
+
104
+ const imgBuf = readFileSync(imgPath);
105
+ const imgHash = sha1Hex(imgBuf);
106
+ const { width: w, height: h } = await getImageDimensions(imgPath);
107
+
108
+ const tmpThumb = `/tmp/openfig_thumb_${Date.now()}.png`;
109
+ await generateThumbnail(imgPath, tmpThumb);
110
+ const thumbHash = sha1Hex(readFileSync(tmpThumb));
111
+
112
+ copyToImages(deck, imgHash, imgPath);
113
+ copyToImages(deck, thumbHash, tmpThumb);
114
+
115
+ newInst.symbolData.symbolOverrides.push(
116
+ imageOv(key, imgHash, thumbHash, w, h)
117
+ );
118
+ }
119
+
120
+ // Set slide position
121
+ const activeSlides = deck.getActiveSlides();
122
+ if (newSlide.transform) {
123
+ newSlide.transform.m02 = activeSlides.length * 2160;
124
+ }
125
+
126
+ // Push to nodeChanges
127
+ deck.message.nodeChanges.push(newSlide);
128
+ deck.message.nodeChanges.push(newInst);
129
+ deck.rebuildMaps();
130
+
131
+ console.log(`Cloned slide "${tmplSlide.name}" → "${newName}" (1:${slideId} + 1:${instId})`);
132
+ console.log(` ${sets.length} text override(s), ${setImages.length} image override(s)`);
133
+
134
+ const bytes = await deck.saveDeck(outPath);
135
+ console.log(`Saved: ${outPath} (${bytes} bytes)`);
136
+ }
137
+
138
+ function copyToImages(deck, hash, srcPath) {
139
+ if (!deck.imagesDir) {
140
+ deck.imagesDir = `/tmp/openfig_images_${Date.now()}`;
141
+ mkdirSync(deck.imagesDir, { recursive: true });
142
+ }
143
+ const dest = join(deck.imagesDir, hash);
144
+ if (!existsSync(dest)) {
145
+ copyFileSync(srcPath, dest);
146
+ }
147
+ }
148
+
149
+ function findSlide(deck, ref) {
150
+ const byId = deck.getNode(ref);
151
+ if (byId?.type === 'SLIDE') return byId;
152
+ return deck.getSlides().find(s => s.name === ref);
153
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * export — Export slides from a .deck file to images.
3
+ *
4
+ * Usage:
5
+ * openfig export <file.deck> [options]
6
+ *
7
+ * Options:
8
+ * -o <dir> Output directory (default: <deckname>/)
9
+ * --slide <n> Export only slide N (1-based). Omit to export all.
10
+ * --scale <n> Zoom factor: 1 = 1920×1080, 0.5 = 960×540 (default: 1)
11
+ * --width <px> Output width in pixels (height scales proportionally)
12
+ * --format <fmt> Output format: png, jpg, webp (default: png)
13
+ * --fonts <dir> Extra font directory to load (can repeat)
14
+ */
15
+
16
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs';
17
+ import { join, parse, resolve } from 'path';
18
+ import { createInterface } from 'readline';
19
+ import { FigDeck } from '../lib/core/fig-deck.mjs';
20
+ import { renderDeck, registerFontDir } from '../lib/rasterizer/deck-rasterizer.mjs';
21
+ import { resolveFonts } from '../lib/rasterizer/font-resolver.mjs';
22
+
23
+ async function confirmOverwrite(dir) {
24
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
25
+ return new Promise((resolve) => {
26
+ console.log(`Output directory "${dir}" already exists.`);
27
+ rl.question(`Delete and replace all contents? (y/N) `, (answer) => {
28
+ rl.close();
29
+ resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
30
+ });
31
+ });
32
+ }
33
+
34
+ export async function run(args, flags) {
35
+ const file = args[0];
36
+ if (!file) {
37
+ console.error('Usage: openfig export <file.deck> [options]\n');
38
+ console.error('Options:');
39
+ console.error(' -o <dir> Output directory (default: <deckname>/)');
40
+ console.error(' --slide <n> Export only slide N (1-based)');
41
+ console.error(' --scale <n> Zoom factor: 1 = 1920×1080, 0.5 = 960×540 (default: 1)');
42
+ console.error(' --width <px> Output width in pixels (height scales proportionally)');
43
+ console.error(' --format <fmt> Output format: png, jpg, webp (default: png)');
44
+ console.error(' --fonts <dir> Extra font directory to load');
45
+ process.exit(1);
46
+ }
47
+
48
+ const defaultOutDir = parse(file).name;
49
+ const outDir = resolve(flags.o ?? flags.output ?? defaultOutDir);
50
+
51
+ if (existsSync(outDir)) {
52
+ const confirmed = await confirmOverwrite(outDir);
53
+ if (!confirmed) {
54
+ console.log('Aborted.');
55
+ process.exit(0);
56
+ }
57
+ rmSync(outDir, { recursive: true });
58
+ }
59
+
60
+ const renderOpts = {};
61
+ if (flags.width) renderOpts.width = parseInt(flags.width);
62
+ else if (flags.scale) renderOpts.scale = parseFloat(flags.scale);
63
+
64
+ const fontDirs = [].concat(flags.fonts ?? []);
65
+ for (const d of fontDirs) registerFontDir(resolve(d));
66
+
67
+ const deck = await FigDeck.fromDeckFile(file);
68
+ await resolveFonts(deck, { quiet: false });
69
+ mkdirSync(outDir, { recursive: true });
70
+
71
+ const slideFilter = flags.slide ? parseInt(flags.slide) : null;
72
+ const slides = await renderDeck(deck, renderOpts);
73
+
74
+ for (const { index, slideId, png } of slides) {
75
+ if (slideFilter && index + 1 !== slideFilter) continue;
76
+ const outFile = join(outDir, `slide_${String(index + 1).padStart(3, '0')}.png`);
77
+ writeFileSync(outFile, png);
78
+ console.log(` slide ${index + 1} → ${outFile}`);
79
+ }
80
+
81
+ const count = slideFilter ? 1 : slides.length;
82
+ console.log(`\nExported ${count} slide(s) to ${outDir}`);
83
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * insert-image — Apply an image fill override to a slide instance.
3
+ *
4
+ * Usage: node cli.mjs insert-image <file.deck> -o <output.deck> --slide <id|name> --key <overrideKey> --image <path.png> [--thumb <thumb.png>]
5
+ */
6
+ import { FigDeck } from '../lib/core/fig-deck.mjs';
7
+ import { nid, parseId } from '../lib/core/node-helpers.mjs';
8
+ import { imageOv } from '../lib/core/image-helpers.mjs';
9
+ import { readFileSync, copyFileSync, existsSync, mkdirSync } from 'fs';
10
+ import { createHash } from 'crypto';
11
+ import { join, resolve } from 'path';
12
+ import { getImageDimensions, generateThumbnail } from '../lib/core/image-utils.mjs';
13
+
14
+ function sha1Hex(buf) {
15
+ return createHash('sha1').update(buf).digest('hex');
16
+ }
17
+
18
+ export async function run(args, flags) {
19
+ const file = args[0];
20
+ const outPath = flags.o || flags.output;
21
+ const slideRef = flags.slide;
22
+ const keyStr = flags.key;
23
+ const imagePath = flags.image;
24
+ const thumbPath = flags.thumb || null;
25
+
26
+ if (!file || !outPath || !slideRef || !keyStr || !imagePath) {
27
+ console.error('Usage: insert-image <file.deck> -o <out.deck> --slide <id|name> --key <key> --image <path.png> [--thumb <thumb.png>]');
28
+ process.exit(1);
29
+ }
30
+
31
+ const deck = await FigDeck.fromDeckFile(file);
32
+
33
+ // Find slide
34
+ const slide = findSlide(deck, slideRef);
35
+ if (!slide) { console.error(`Slide not found: ${slideRef}`); process.exit(1); }
36
+
37
+ const inst = deck.getSlideInstance(nid(slide));
38
+ if (!inst) { console.error(`No instance on slide ${nid(slide)}`); process.exit(1); }
39
+
40
+ const imgBuf = readFileSync(resolve(imagePath));
41
+ const imgHash = sha1Hex(imgBuf);
42
+ const { width, height } = await getImageDimensions(resolve(imagePath));
43
+
44
+ let thumbHash;
45
+ if (thumbPath) {
46
+ const tBuf = readFileSync(resolve(thumbPath));
47
+ thumbHash = sha1Hex(tBuf);
48
+ copyToImages(deck, thumbHash, resolve(thumbPath));
49
+ } else {
50
+ const tmpThumb = `/tmp/openfig_thumb_${Date.now()}.png`;
51
+ await generateThumbnail(resolve(imagePath), tmpThumb);
52
+ thumbHash = sha1Hex(readFileSync(tmpThumb));
53
+ copyToImages(deck, thumbHash, tmpThumb);
54
+ }
55
+
56
+ // Copy full image to images dir
57
+ copyToImages(deck, imgHash, resolve(imagePath));
58
+
59
+ // Build and apply override
60
+ const key = parseId(keyStr);
61
+ const override = imageOv(key, imgHash, thumbHash, width, height);
62
+
63
+ if (!inst.symbolData) inst.symbolData = {};
64
+ if (!inst.symbolData.symbolOverrides) inst.symbolData.symbolOverrides = [];
65
+ inst.symbolData.symbolOverrides.push(override);
66
+
67
+ console.log(`Image: ${imgHash} (${width}×${height})`);
68
+ console.log(`Thumb: ${thumbHash}`);
69
+ console.log(`Applied to slide "${slide.name || nid(slide)}" key ${keyStr}`);
70
+
71
+ const bytes = await deck.saveDeck(outPath);
72
+ console.log(`Saved: ${outPath} (${bytes} bytes)`);
73
+ }
74
+
75
+ function copyToImages(deck, hash, srcPath) {
76
+ if (!deck.imagesDir) {
77
+ deck.imagesDir = `/tmp/openfig_images_${Date.now()}`;
78
+ mkdirSync(deck.imagesDir, { recursive: true });
79
+ }
80
+ const dest = join(deck.imagesDir, hash);
81
+ if (!existsSync(dest)) {
82
+ copyFileSync(srcPath, dest);
83
+ }
84
+ }
85
+
86
+ function findSlide(deck, ref) {
87
+ const byId = deck.getNode(ref);
88
+ if (byId?.type === 'SLIDE') return byId;
89
+ return deck.getActiveSlides().find(s => s.name === ref);
90
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * inspect — Show document structure (node hierarchy tree).
3
+ *
4
+ * Usage: node cli.mjs inspect <file.deck|file.fig> [--depth N] [--type TYPE] [--json]
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: inspect <file.deck|file.fig>'); process.exit(1); }
12
+
13
+ const maxDepth = flags.depth ? parseInt(flags.depth) : Infinity;
14
+ const filterType = flags.type || null;
15
+ const jsonOut = flags.json != null;
16
+
17
+ const deck = file.endsWith('.fig')
18
+ ? FigDeck.fromFigFile(file)
19
+ : await FigDeck.fromDeckFile(file);
20
+
21
+ // Find root nodes (no parentIndex or parent not in nodeMap)
22
+ const roots = deck.message.nodeChanges.filter(n => {
23
+ if (!n.parentIndex?.guid) return true;
24
+ const pid = `${n.parentIndex.guid.sessionID}:${n.parentIndex.guid.localID}`;
25
+ return !deck.nodeMap.has(pid);
26
+ });
27
+
28
+ if (jsonOut) {
29
+ const collect = [];
30
+ for (const root of roots) {
31
+ collectJson(deck, nid(root), 0, maxDepth, filterType, collect);
32
+ }
33
+ console.log(JSON.stringify(collect, null, 2));
34
+ return;
35
+ }
36
+
37
+ // Summary
38
+ const slides = deck.getSlides();
39
+ const active = deck.getActiveSlides();
40
+ console.log(`Nodes: ${deck.message.nodeChanges.length} Slides: ${active.length} active / ${slides.length} total Blobs: ${deck.message.blobs?.length || 0}`);
41
+ if (deck.deckMeta) console.log(`Deck name: ${deck.deckMeta.file_name || '(unknown)'}`);
42
+ console.log('');
43
+
44
+ for (const root of roots) {
45
+ printTree(deck, nid(root), 0, maxDepth, filterType);
46
+ }
47
+ }
48
+
49
+ function printTree(deck, id, depth, maxDepth, filterType) {
50
+ if (depth > maxDepth) return;
51
+ const node = deck.getNode(id);
52
+ if (!node) return;
53
+
54
+ const type = node.type || '?';
55
+ const show = !filterType || type === filterType;
56
+
57
+ if (show) {
58
+ const indent = ' '.repeat(depth);
59
+ const name = node.name ? `"${node.name}"` : '';
60
+ const removed = node.phase === 'REMOVED' ? ' [REMOVED]' : '';
61
+ const sym = node.symbolData?.symbolID
62
+ ? ` sym=${node.symbolData.symbolID.sessionID}:${node.symbolData.symbolID.localID}`
63
+ : '';
64
+ const ovCount = node.symbolData?.symbolOverrides?.length;
65
+ const ovs = ovCount ? ` overrides=${ovCount}` : '';
66
+ console.log(`${indent}${type} ${name} (${id})${removed}${sym}${ovs}`);
67
+ }
68
+
69
+ for (const child of deck.getChildren(id)) {
70
+ printTree(deck, nid(child), depth + 1, maxDepth, filterType);
71
+ }
72
+ }
73
+
74
+ function collectJson(deck, id, depth, maxDepth, filterType, out) {
75
+ if (depth > maxDepth) return;
76
+ const node = deck.getNode(id);
77
+ if (!node) return;
78
+ const type = node.type || '?';
79
+ if (!filterType || type === filterType) {
80
+ out.push({
81
+ id, type, name: node.name || null,
82
+ phase: node.phase || null,
83
+ symbolID: node.symbolData?.symbolID ? nid({ guid: node.symbolData.symbolID }) : null,
84
+ overrides: node.symbolData?.symbolOverrides?.length || 0,
85
+ depth,
86
+ });
87
+ }
88
+ for (const child of deck.getChildren(id)) {
89
+ collectJson(deck, nid(child), depth + 1, maxDepth, filterType, out);
90
+ }
91
+ }