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
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
|
+
}
|