@deckspec/cli 0.1.3 → 0.1.5
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deckspec/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"files": [
|
|
5
5
|
"dist",
|
|
6
6
|
"src",
|
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
"react": "^19.0.0",
|
|
19
19
|
"react-dom": "^19.0.0",
|
|
20
20
|
"zod": "^3.23.0",
|
|
21
|
-
"@deckspec/dsl": "0.1.
|
|
22
|
-
"@deckspec/schema": "0.1.
|
|
23
|
-
"@deckspec/renderer": "0.1.
|
|
21
|
+
"@deckspec/dsl": "0.1.5",
|
|
22
|
+
"@deckspec/schema": "0.1.5",
|
|
23
|
+
"@deckspec/renderer": "0.1.5"
|
|
24
24
|
},
|
|
25
25
|
"license": "Apache-2.0",
|
|
26
26
|
"repository": {
|
|
@@ -22,7 +22,7 @@ npx deckspec render decks/<deck-name>/deck.yaml -o output/<deck-name>
|
|
|
22
22
|
### 2. Run the screenshot script
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
|
-
node
|
|
25
|
+
node .claude/skills/deckspec-screenshot/screenshot-deck.mjs output/<deck-name>/index.html output/<deck-name>-slides
|
|
26
26
|
```
|
|
27
27
|
|
|
28
28
|
This will:
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { chromium } from "playwright";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { mkdirSync } from "node:fs";
|
|
5
|
+
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
const htmlPath = resolve(args[0] ?? "output/hashimotoya-fuel.html");
|
|
8
|
+
const outDir = resolve(args[1] ?? "output/hashimotoya-fuel-slides");
|
|
9
|
+
mkdirSync(outDir, { recursive: true });
|
|
10
|
+
|
|
11
|
+
const browser = await chromium.launch({ headless: true });
|
|
12
|
+
// Use the native slide resolution for pixel-perfect screenshots
|
|
13
|
+
const page = await browser.newPage({ viewport: { width: 1200, height: 675 } });
|
|
14
|
+
await page.goto(`file://${htmlPath}`);
|
|
15
|
+
await page.waitForTimeout(1000);
|
|
16
|
+
|
|
17
|
+
// Force slide to render at native 1200x675 without viewer scaling
|
|
18
|
+
await page.evaluate(() => {
|
|
19
|
+
// Hide navigation
|
|
20
|
+
const nav = document.querySelector(".nav-controls");
|
|
21
|
+
if (nav) nav.style.display = "none";
|
|
22
|
+
|
|
23
|
+
// Remove body flex centering — render slide flush at top-left
|
|
24
|
+
document.body.style.display = "block";
|
|
25
|
+
document.body.style.overflow = "hidden";
|
|
26
|
+
document.body.style.background = "transparent";
|
|
27
|
+
|
|
28
|
+
// Make active slide-outer fill viewport exactly at native size
|
|
29
|
+
const outers = document.querySelectorAll(".slide-outer");
|
|
30
|
+
outers.forEach((el) => {
|
|
31
|
+
el.style.width = "1200px";
|
|
32
|
+
el.style.height = "675px";
|
|
33
|
+
// Remove scaling — render at 1:1
|
|
34
|
+
const slide = el.querySelector(".slide, .slide-pad, .slide-stack, .slide-center, .slide-white");
|
|
35
|
+
if (slide) {
|
|
36
|
+
slide.style.transform = "none";
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const total = await page.evaluate(() =>
|
|
42
|
+
document.querySelectorAll(".slide-outer").length
|
|
43
|
+
);
|
|
44
|
+
console.log(`Found ${total} slides`);
|
|
45
|
+
|
|
46
|
+
for (let i = 0; i < total; i++) {
|
|
47
|
+
if (i > 0) {
|
|
48
|
+
await page.keyboard.press("ArrowRight");
|
|
49
|
+
await page.waitForTimeout(300);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Screenshot just the active slide-outer element for pixel-perfect capture
|
|
53
|
+
const slideEl = await page.$(".slide-outer.active");
|
|
54
|
+
if (slideEl) {
|
|
55
|
+
const filename = `${outDir}/slide-${String(i + 1).padStart(2, "0")}.png`;
|
|
56
|
+
await slideEl.screenshot({ path: filename });
|
|
57
|
+
console.log(`✓ ${filename}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
await browser.close();
|
|
62
|
+
console.log(`\n✓ Saved ${total} slides to ${outDir}/`);
|
|
@@ -7,6 +7,12 @@ description: "Use this skill when the user asks to convert a DeckSpec deck to Po
|
|
|
7
7
|
|
|
8
8
|
Convert a DeckSpec deck.yaml to a PowerPoint (.pptx) file.
|
|
9
9
|
|
|
10
|
+
## How It Works
|
|
11
|
+
|
|
12
|
+
The conversion script (`deck-to-pptx.mjs`) reads `deck.yaml`, looks up each slide's `file:` pattern name in a `renderers` registry, and calls the matching renderer function to build the slide using pptxgenjs.
|
|
13
|
+
|
|
14
|
+
**Patterns without a registered renderer produce blank slides with a warning.** When you encounter this, you need to add a renderer function for that pattern.
|
|
15
|
+
|
|
10
16
|
## Prerequisites
|
|
11
17
|
|
|
12
18
|
- pptxgenjs installed: `npm install -D pptxgenjs`
|
|
@@ -16,21 +22,227 @@ Convert a DeckSpec deck.yaml to a PowerPoint (.pptx) file.
|
|
|
16
22
|
### 1. Run the conversion
|
|
17
23
|
|
|
18
24
|
```bash
|
|
19
|
-
node
|
|
25
|
+
node .claude/skills/deckspec-to-pptx/deck-to-pptx.mjs decks/<deck-name>/deck.yaml -o output/<deck-name>.pptx
|
|
20
26
|
```
|
|
21
27
|
|
|
22
28
|
### 2. Check for warnings
|
|
23
29
|
|
|
24
|
-
|
|
30
|
+
```
|
|
31
|
+
⚠ No pptx renderer for pattern "my-pattern" — blank slide
|
|
32
|
+
```
|
|
25
33
|
|
|
26
|
-
|
|
34
|
+
If you see this, add a renderer for that pattern (see "Adding a Renderer" below).
|
|
27
35
|
|
|
28
36
|
### 3. Open and verify
|
|
29
37
|
|
|
30
|
-
Open the .pptx in PowerPoint or Google Slides to verify.
|
|
38
|
+
Open the .pptx in PowerPoint, Keynote, or Google Slides to verify.
|
|
39
|
+
|
|
40
|
+
## Adding a Renderer
|
|
41
|
+
|
|
42
|
+
When a pattern has no pptx renderer, you need to add one in `deck-to-pptx.mjs`.
|
|
43
|
+
|
|
44
|
+
### Step 1: Read the pattern's React source
|
|
45
|
+
|
|
46
|
+
Look at `themes/<theme>/patterns/<name>/index.tsx` to understand:
|
|
47
|
+
- The Zod schema (what `vars` are available)
|
|
48
|
+
- The layout structure (CSS classes, grid, cards, etc.)
|
|
49
|
+
- What content goes where
|
|
50
|
+
|
|
51
|
+
### Step 2: Write the renderer function
|
|
52
|
+
|
|
53
|
+
Add a function in the `// ─── Slide renderers ───` section of `deck-to-pptx.mjs`:
|
|
54
|
+
|
|
55
|
+
```javascript
|
|
56
|
+
function renderMyPattern(slide, vars) {
|
|
57
|
+
const padX = px(64); // horizontal padding
|
|
58
|
+
const padY = px(48); // vertical padding
|
|
59
|
+
|
|
60
|
+
// Label (small uppercase text at top)
|
|
61
|
+
if (vars.label) {
|
|
62
|
+
slide.addText(vars.label.toUpperCase(), {
|
|
63
|
+
x: padX, y: padY, w: W - 2 * padX, h: 0.2,
|
|
64
|
+
fontSize: 9, fontFace: FONT_B, bold: true,
|
|
65
|
+
color: C.primary, charSpacing: 1.5, margin: 0,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Heading
|
|
70
|
+
slide.addText(vars.heading, {
|
|
71
|
+
x: padX, y: padY + 0.25, w: W - 2 * padX, h: 0.35,
|
|
72
|
+
fontSize: 18, fontFace: FONT_H, bold: true,
|
|
73
|
+
color: C.fg, margin: 0,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ... add more elements
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Step 3: Register the renderer
|
|
81
|
+
|
|
82
|
+
Add it to the `renderers` object at the bottom of the file:
|
|
83
|
+
|
|
84
|
+
```javascript
|
|
85
|
+
const renderers = {
|
|
86
|
+
// ... existing renderers
|
|
87
|
+
"my-pattern": renderMyPattern,
|
|
88
|
+
};
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Step 4: Re-run and verify
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
node .claude/skills/deckspec-to-pptx/deck-to-pptx.mjs decks/<deck>/deck.yaml -o output/<deck>.pptx
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## PptxGenJS API Reference
|
|
98
|
+
|
|
99
|
+
### Available Constants
|
|
100
|
+
|
|
101
|
+
| Constant | Description |
|
|
102
|
+
|----------|-------------|
|
|
103
|
+
| `C.primary` | Primary color (hex without #) |
|
|
104
|
+
| `C.fg` | Foreground/text color |
|
|
105
|
+
| `C.bg` | Background color |
|
|
106
|
+
| `C.muted` | Muted text color |
|
|
107
|
+
| `C.border` | Border color |
|
|
108
|
+
| `C.card` | Card background color |
|
|
109
|
+
| `FONT_H` | Heading font (Noto Sans JP) |
|
|
110
|
+
| `FONT_B` | Body font (Noto Sans JP) |
|
|
111
|
+
| `W` | Slide width in inches (10) |
|
|
112
|
+
| `H` | Slide height in inches (5.625) |
|
|
113
|
+
| `px(v)` | Convert CSS pixels to inches (1200px = 10in) |
|
|
114
|
+
|
|
115
|
+
### Core Methods
|
|
116
|
+
|
|
117
|
+
```javascript
|
|
118
|
+
// Text
|
|
119
|
+
slide.addText("Hello", {
|
|
120
|
+
x: px(64), y: px(48), w: 5, h: 0.4,
|
|
121
|
+
fontSize: 18, fontFace: FONT_H, bold: true,
|
|
122
|
+
color: C.fg, align: "center", valign: "middle",
|
|
123
|
+
margin: 0,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Rich text (multiple styles in one box)
|
|
127
|
+
slide.addText([
|
|
128
|
+
{ text: "Bold part", options: { bold: true, fontSize: 14, color: C.fg } },
|
|
129
|
+
{ text: " normal part", options: { fontSize: 12, color: C.muted } },
|
|
130
|
+
], { x: 1, y: 1, w: 8, h: 0.5, margin: 0 });
|
|
131
|
+
|
|
132
|
+
// Shape
|
|
133
|
+
slide.addShape(pres.shapes.RECTANGLE, {
|
|
134
|
+
x: 1, y: 1, w: 3, h: 2,
|
|
135
|
+
fill: { color: C.card },
|
|
136
|
+
line: { color: C.border, width: 1 },
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Line
|
|
140
|
+
slide.addShape(pres.shapes.LINE, {
|
|
141
|
+
x: 1, y: 1, w: 5, h: 0, // horizontal line (h=0)
|
|
142
|
+
line: { color: C.border, width: 0.5 },
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Circle/Oval
|
|
146
|
+
slide.addShape(pres.shapes.OVAL, {
|
|
147
|
+
x: 1, y: 1, w: 1, h: 1,
|
|
148
|
+
fill: { color: C.bg },
|
|
149
|
+
line: { color: C.fg, width: 1.5 },
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Table
|
|
153
|
+
slide.addTable(rows, {
|
|
154
|
+
x: px(64), y: 1, w: W - 2 * px(64),
|
|
155
|
+
colW: [1.5, 3, 3],
|
|
156
|
+
border: { type: "solid", pt: 0.5, color: C.border },
|
|
157
|
+
rowH: [0.4, 0.35, 0.35],
|
|
158
|
+
margin: [4, 8, 4, 8],
|
|
159
|
+
autoPage: false,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Image (base64)
|
|
163
|
+
slide.addImage({
|
|
164
|
+
data: "image/png;base64,...",
|
|
165
|
+
x: 1, y: 1, w: 4, h: 3,
|
|
166
|
+
});
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Important Rules
|
|
170
|
+
|
|
171
|
+
1. **Colors are 6-char hex WITHOUT `#`** — `"E5001F"` not `"#E5001F"`
|
|
172
|
+
2. **Use `charSpacing` not `letterSpacing`** — pptxgenjs uses its own property name
|
|
173
|
+
3. **Always set `margin: 0`** on text boxes for precise alignment
|
|
174
|
+
4. **Never reuse option objects** between `addText`/`addShape` calls — pptxgenjs mutates them
|
|
175
|
+
5. **`breakLine: true`** in rich text options to force line break after that segment
|
|
176
|
+
6. **`strike: true`** for strikethrough text (useful for "before" prices)
|
|
177
|
+
7. **`highlight: "FF0000"`** to add text highlight/background color on rich text segments
|
|
178
|
+
|
|
179
|
+
### Common Layout Patterns
|
|
180
|
+
|
|
181
|
+
**Label + Heading + Content** (most common):
|
|
182
|
+
```javascript
|
|
183
|
+
// Top: small label
|
|
184
|
+
// Below: large heading
|
|
185
|
+
// Rest: content area
|
|
186
|
+
const padX = px(64);
|
|
187
|
+
const padY = px(48);
|
|
188
|
+
const contentTop = padY + 0.85; // below heading
|
|
189
|
+
const contentBot = H - padY;
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
**Card Grid**:
|
|
193
|
+
```javascript
|
|
194
|
+
const items = vars.items;
|
|
195
|
+
const gap = px(1);
|
|
196
|
+
const cardW = (W - 2 * padX - gap * (items.length - 1)) / items.length;
|
|
197
|
+
|
|
198
|
+
items.forEach((item, i) => {
|
|
199
|
+
const cx = padX + i * (cardW + gap);
|
|
200
|
+
slide.addShape(pres.shapes.RECTANGLE, {
|
|
201
|
+
x: cx, y: cardTop, w: cardW, h: cardH,
|
|
202
|
+
fill: { color: C.card },
|
|
203
|
+
});
|
|
204
|
+
// Add text inside card...
|
|
205
|
+
});
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**Two-Column Layout**:
|
|
209
|
+
```javascript
|
|
210
|
+
const leftW = (W - 2 * padX) * 0.5;
|
|
211
|
+
const rightX = padX + leftW + px(48);
|
|
212
|
+
const rightW = W - rightX - padX;
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Helper Functions
|
|
216
|
+
|
|
217
|
+
```javascript
|
|
218
|
+
// resolveImage(filename) — converts local file to base64 data URI
|
|
219
|
+
const imgData = resolveImage(vars.image);
|
|
220
|
+
if (imgData) {
|
|
221
|
+
slide.addImage({ data: imgData, x: 1, y: 1, w: 4, h: 3 });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// getImageSize(filename) — returns { w, h } in pixels (macOS sips)
|
|
225
|
+
const size = getImageSize(vars.image);
|
|
226
|
+
|
|
227
|
+
// containImage(imgW, imgH, boxW, boxH) — fit preserving aspect ratio
|
|
228
|
+
const fit = containImage(size.w, size.h, boxW, boxH);
|
|
229
|
+
slide.addImage({
|
|
230
|
+
data: imgData,
|
|
231
|
+
x: boxX + fit.offX, y: boxY + fit.offY,
|
|
232
|
+
w: fit.w, h: fit.h,
|
|
233
|
+
});
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Registered Renderers
|
|
237
|
+
|
|
238
|
+
The following patterns have pptx renderers pre-built:
|
|
31
239
|
|
|
32
|
-
|
|
240
|
+
| Pattern | Layout |
|
|
241
|
+
|---------|--------|
|
|
242
|
+
| `price-before-after` | Before→After price comparison cards |
|
|
243
|
+
| `comparison-table` | Multi-column table with icon indicators |
|
|
244
|
+
| `image-full` | Header + full-width image |
|
|
245
|
+
| `conclusion-summary` | Logo + heading + summary points |
|
|
246
|
+
| `closing-triad` | Two-column: message + triangle diagram |
|
|
33
247
|
|
|
34
|
-
|
|
35
|
-
- Local images are embedded as base64
|
|
36
|
-
- Not all patterns have pptx renderers yet — unregistered patterns produce blank slides
|
|
248
|
+
All other patterns will produce blank slides until you add a renderer.
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* deck-to-pptx.mjs — Convert a DeckSpec deck.yaml to PowerPoint (.pptx)
|
|
4
|
+
*
|
|
5
|
+
* Usage: node .claude/skills/deckspec-to-pptx/deck-to-pptx.mjs decks/my-deck/deck.yaml -o output/my-deck.pptx
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
8
|
+
import { resolve, dirname, join } from "node:path";
|
|
9
|
+
import { execSync } from "node:child_process";
|
|
10
|
+
import { load as loadYaml } from "js-yaml";
|
|
11
|
+
import PptxGenJS from "pptxgenjs";
|
|
12
|
+
|
|
13
|
+
// ─── Args ───────────────────────────────────────────────────────────
|
|
14
|
+
const args = process.argv.slice(2);
|
|
15
|
+
const inputIdx = args.findIndex((a) => !a.startsWith("-"));
|
|
16
|
+
const outputIdx = args.indexOf("-o");
|
|
17
|
+
if (inputIdx < 0 || outputIdx < 0 || !args[outputIdx + 1]) {
|
|
18
|
+
console.error("Usage: node deck-to-pptx.mjs <deck.yaml> -o <output.pptx>");
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
const inputPath = resolve(args[inputIdx]);
|
|
22
|
+
const outputPath = resolve(args[outputIdx + 1]);
|
|
23
|
+
const basePath = dirname(inputPath);
|
|
24
|
+
|
|
25
|
+
// ─── Load deck.yaml & tokens ────────────────────────────────────────
|
|
26
|
+
const deck = loadYaml(readFileSync(inputPath, "utf-8"));
|
|
27
|
+
const themeName = deck.meta?.theme ?? "noir-display";
|
|
28
|
+
const themeDir = resolve("themes", themeName);
|
|
29
|
+
const tokens = JSON.parse(readFileSync(join(themeDir, "tokens.json"), "utf-8"));
|
|
30
|
+
|
|
31
|
+
// ─── Color / Font helpers ───────────────────────────────────────────
|
|
32
|
+
const hex = (c) => c.replace(/^#/, "");
|
|
33
|
+
const C = {
|
|
34
|
+
primary: hex(tokens.colors.primary),
|
|
35
|
+
fg: hex(tokens.colors.foreground),
|
|
36
|
+
bg: hex(tokens.colors.background),
|
|
37
|
+
muted: hex(tokens.colors["muted-foreground"]),
|
|
38
|
+
border: hex(tokens.colors.border),
|
|
39
|
+
card: hex(tokens.colors["card-background"] ?? "#ffffff"),
|
|
40
|
+
};
|
|
41
|
+
const FONT_H = "Noto Sans JP";
|
|
42
|
+
const FONT_B = "Noto Sans JP";
|
|
43
|
+
|
|
44
|
+
// Slide inches
|
|
45
|
+
const W = 10;
|
|
46
|
+
const H = 5.625;
|
|
47
|
+
|
|
48
|
+
// px→inch (1200px = 10in)
|
|
49
|
+
const px = (v) => v / 120;
|
|
50
|
+
|
|
51
|
+
// ─── Image helper (local file → base64 data URI) ───────────────────
|
|
52
|
+
function resolveImage(filename) {
|
|
53
|
+
if (!filename) return null;
|
|
54
|
+
if (filename.startsWith("data:") || filename.startsWith("http")) return filename;
|
|
55
|
+
const abs = resolve(basePath, filename);
|
|
56
|
+
const buf = readFileSync(abs);
|
|
57
|
+
const ext = filename.split(".").pop().toLowerCase();
|
|
58
|
+
const mime = ext === "png" ? "image/png" : ext === "svg" ? "image/svg+xml" : "image/jpeg";
|
|
59
|
+
return `${mime};base64,${buf.toString("base64")}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Get image pixel dimensions via sips (macOS) */
|
|
63
|
+
function getImageSize(filename) {
|
|
64
|
+
if (!filename || filename.startsWith("data:") || filename.startsWith("http")) return null;
|
|
65
|
+
const abs = resolve(basePath, filename);
|
|
66
|
+
try {
|
|
67
|
+
const out = execSync(`sips -g pixelWidth -g pixelHeight "${abs}"`, { encoding: "utf-8" });
|
|
68
|
+
const w = parseInt(out.match(/pixelWidth:\s*(\d+)/)?.[1] ?? "0");
|
|
69
|
+
const h = parseInt(out.match(/pixelHeight:\s*(\d+)/)?.[1] ?? "0");
|
|
70
|
+
return w > 0 && h > 0 ? { w, h } : null;
|
|
71
|
+
} catch { return null; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Fit image within box preserving aspect ratio, centered */
|
|
75
|
+
function containImage(imgW, imgH, boxW, boxH) {
|
|
76
|
+
const imgRatio = imgW / imgH;
|
|
77
|
+
const boxRatio = boxW / boxH;
|
|
78
|
+
let w, h;
|
|
79
|
+
if (imgRatio > boxRatio) {
|
|
80
|
+
w = boxW;
|
|
81
|
+
h = boxW / imgRatio;
|
|
82
|
+
} else {
|
|
83
|
+
h = boxH;
|
|
84
|
+
w = boxH * imgRatio;
|
|
85
|
+
}
|
|
86
|
+
const x = (boxW - w) / 2;
|
|
87
|
+
const y = (boxH - h) / 2;
|
|
88
|
+
return { w, h, offX: x, offY: y };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Icon color map ─────────────────────────────────────────────────
|
|
92
|
+
const iconColors = { good: "2563EB", bad: C.primary, warn: "D97706" };
|
|
93
|
+
const iconSymbol = { good: "●", bad: "✕", warn: "▲" };
|
|
94
|
+
|
|
95
|
+
// ─── Create presentation ────────────────────────────────────────────
|
|
96
|
+
const pres = new PptxGenJS();
|
|
97
|
+
pres.layout = "LAYOUT_16x9";
|
|
98
|
+
pres.title = deck.meta?.title ?? "Untitled";
|
|
99
|
+
pres.author = "DeckSpec";
|
|
100
|
+
|
|
101
|
+
// ─── Slide renderers ────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
function renderPriceBeforeAfter(slide, vars) {
|
|
104
|
+
const padX = px(64);
|
|
105
|
+
const padY = px(48);
|
|
106
|
+
|
|
107
|
+
slide.addText(vars.label.toUpperCase(), {
|
|
108
|
+
x: padX, y: padY, w: W - 2 * padX, h: 0.2,
|
|
109
|
+
fontSize: 9, fontFace: FONT_B, bold: true,
|
|
110
|
+
color: C.primary, charSpacing: 1.5, margin: 0,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
slide.addText(vars.heading, {
|
|
114
|
+
x: padX, y: padY + 0.25, w: W - 2 * padX, h: 0.35,
|
|
115
|
+
fontSize: 18, fontFace: FONT_H, bold: true,
|
|
116
|
+
color: C.fg, margin: 0,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const items = vars.items;
|
|
120
|
+
const cardTop = padY + 0.85;
|
|
121
|
+
const cardBot = H - padY - (vars.footnote ? 0.35 : 0);
|
|
122
|
+
const cardH = cardBot - cardTop;
|
|
123
|
+
const gap = px(1);
|
|
124
|
+
const cardW = (W - 2 * padX - gap * (items.length - 1)) / items.length;
|
|
125
|
+
|
|
126
|
+
items.forEach((item, i) => {
|
|
127
|
+
const cx = padX + i * (cardW + gap);
|
|
128
|
+
slide.addShape(pres.shapes.RECTANGLE, {
|
|
129
|
+
x: cx, y: cardTop, w: cardW, h: cardH, fill: { color: C.card },
|
|
130
|
+
});
|
|
131
|
+
slide.addText(item.name, {
|
|
132
|
+
x: cx, y: cardTop + px(32), w: cardW, h: 0.35,
|
|
133
|
+
fontSize: 15, fontFace: FONT_H, bold: true, color: C.fg, align: "center", margin: 0,
|
|
134
|
+
});
|
|
135
|
+
const priceY = cardTop + cardH * 0.35;
|
|
136
|
+
slide.addText("BEFORE", {
|
|
137
|
+
x: cx, y: priceY, w: cardW * 0.38, h: 0.18,
|
|
138
|
+
fontSize: 8, fontFace: FONT_B, bold: true, color: C.muted, align: "center", charSpacing: 1, margin: 0,
|
|
139
|
+
});
|
|
140
|
+
slide.addText(item.before, {
|
|
141
|
+
x: cx, y: priceY + 0.18, w: cardW * 0.38, h: 0.35,
|
|
142
|
+
fontSize: 20, fontFace: FONT_H, bold: true, color: C.muted, align: "center", strike: true, margin: 0,
|
|
143
|
+
});
|
|
144
|
+
slide.addText("→", {
|
|
145
|
+
x: cx + cardW * 0.38, y: priceY + 0.12, w: cardW * 0.24, h: 0.35,
|
|
146
|
+
fontSize: 22, color: C.primary, align: "center", margin: 0,
|
|
147
|
+
});
|
|
148
|
+
slide.addText("AFTER", {
|
|
149
|
+
x: cx + cardW * 0.62, y: priceY, w: cardW * 0.38, h: 0.18,
|
|
150
|
+
fontSize: 8, fontFace: FONT_B, bold: true, color: C.primary, align: "center", charSpacing: 1, margin: 0,
|
|
151
|
+
});
|
|
152
|
+
slide.addText(item.after, {
|
|
153
|
+
x: cx + cardW * 0.62, y: priceY + 0.18, w: cardW * 0.38, h: 0.35,
|
|
154
|
+
fontSize: 26, fontFace: FONT_H, bold: true, color: C.fg, align: "center", margin: 0,
|
|
155
|
+
});
|
|
156
|
+
if (item.note) {
|
|
157
|
+
slide.addText(item.note, {
|
|
158
|
+
x: cx + px(8), y: cardTop + cardH - px(48), w: cardW - px(16), h: 0.3,
|
|
159
|
+
fontSize: 9, fontFace: FONT_B, color: C.muted, align: "center", margin: 0,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
if (vars.footnote) {
|
|
165
|
+
slide.addText(vars.footnote, {
|
|
166
|
+
x: padX, y: H - padY - 0.25, w: W - 2 * padX, h: 0.25,
|
|
167
|
+
fontSize: 9, fontFace: FONT_B, color: C.muted, align: "center", margin: 0,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function renderComparisonTable(slide, vars) {
|
|
173
|
+
const padX = px(64);
|
|
174
|
+
const padY = px(40);
|
|
175
|
+
|
|
176
|
+
slide.addText(vars.label.toUpperCase(), {
|
|
177
|
+
x: padX, y: padY, w: W - 2 * padX, h: 0.2,
|
|
178
|
+
fontSize: 9, fontFace: FONT_B, bold: true,
|
|
179
|
+
color: C.primary, charSpacing: 1.5, margin: 0,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
slide.addText(vars.heading, {
|
|
183
|
+
x: padX, y: padY + 0.22, w: W - 2 * padX, h: 0.35,
|
|
184
|
+
fontSize: 16, fontFace: FONT_H, bold: true, color: C.fg, margin: 0,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const colCount = vars.columns.length;
|
|
188
|
+
const rowLabelW = 1.5;
|
|
189
|
+
const dataW = (W - 2 * padX - rowLabelW) / colCount;
|
|
190
|
+
const colWidths = [rowLabelW, ...Array(colCount).fill(dataW)];
|
|
191
|
+
const badgeColor = { red: C.primary, blue: "2563EB", green: "16A34A" };
|
|
192
|
+
const tableRows = [];
|
|
193
|
+
|
|
194
|
+
const headerRow = [{ text: "", options: { fill: { color: C.fg }, color: C.card } }];
|
|
195
|
+
vars.columns.forEach((col) => {
|
|
196
|
+
const cellText = [
|
|
197
|
+
{ text: col.name, options: { bold: true, fontSize: 12, color: "FFFFFF", breakLine: true } },
|
|
198
|
+
];
|
|
199
|
+
if (col.status) {
|
|
200
|
+
cellText.push({
|
|
201
|
+
text: ` ${col.status}`,
|
|
202
|
+
options: { fontSize: 8, bold: true, color: "FFFFFF", highlight: badgeColor[col.statusColor ?? "red"] },
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
headerRow.push({ text: cellText, options: { fill: { color: C.fg }, color: "FFFFFF", valign: "middle" } });
|
|
206
|
+
});
|
|
207
|
+
tableRows.push(headerRow);
|
|
208
|
+
|
|
209
|
+
vars.rows.forEach((rowLabel, ri) => {
|
|
210
|
+
const rowBg = ri % 2 === 0 ? C.card : C.bg;
|
|
211
|
+
const row = [{
|
|
212
|
+
text: rowLabel,
|
|
213
|
+
options: { fontSize: 9, bold: true, color: C.muted, fill: { color: rowBg }, valign: "middle" },
|
|
214
|
+
}];
|
|
215
|
+
vars.columns.forEach((col) => {
|
|
216
|
+
const cell = col.values[ri];
|
|
217
|
+
const isObj = cell != null && typeof cell === "object";
|
|
218
|
+
const text = isObj ? cell.text : (cell ?? "—");
|
|
219
|
+
const icon = isObj ? cell.icon : undefined;
|
|
220
|
+
const cellContent = [];
|
|
221
|
+
if (icon) {
|
|
222
|
+
cellContent.push({ text: iconSymbol[icon] + " ", options: { color: iconColors[icon], fontSize: 9, bold: true } });
|
|
223
|
+
}
|
|
224
|
+
cellContent.push({ text, options: { fontSize: 10, color: C.fg } });
|
|
225
|
+
row.push({ text: cellContent, options: { fill: { color: rowBg }, valign: "middle" } });
|
|
226
|
+
});
|
|
227
|
+
tableRows.push(row);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const tableY = padY + 0.75;
|
|
231
|
+
const tableH = H - tableY - (vars.footnote ? px(44) : px(32));
|
|
232
|
+
|
|
233
|
+
slide.addTable(tableRows, {
|
|
234
|
+
x: padX, y: tableY, w: W - 2 * padX, h: tableH,
|
|
235
|
+
colW: colWidths,
|
|
236
|
+
border: { type: "solid", pt: 0.5, color: C.border },
|
|
237
|
+
rowH: [0.42, ...Array(vars.rows.length).fill((tableH - 0.42) / vars.rows.length)],
|
|
238
|
+
margin: [4, 8, 4, 8],
|
|
239
|
+
autoPage: false,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
if (vars.footnote) {
|
|
243
|
+
slide.addText(vars.footnote, {
|
|
244
|
+
x: padX, y: H - px(36), w: W - 2 * padX, h: 0.25,
|
|
245
|
+
fontSize: 9, fontFace: FONT_B, color: C.muted, margin: 0,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function renderImageFull(slide, vars) {
|
|
251
|
+
const padX = px(48);
|
|
252
|
+
const padY = px(32);
|
|
253
|
+
|
|
254
|
+
slide.addText(vars.label.toUpperCase(), {
|
|
255
|
+
x: padX, y: padY, w: W - 2 * padX, h: 0.2,
|
|
256
|
+
fontSize: 9, fontFace: FONT_B, bold: true,
|
|
257
|
+
color: C.primary, charSpacing: 1.5, margin: 0,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
slide.addText(vars.heading, {
|
|
261
|
+
x: padX, y: padY + 0.22, w: W - 2 * padX, h: 0.32,
|
|
262
|
+
fontSize: 16, fontFace: FONT_H, bold: true, color: C.fg, margin: 0,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
let imgTop = padY + 0.6;
|
|
266
|
+
if (vars.subtitle) {
|
|
267
|
+
slide.addText(vars.subtitle, {
|
|
268
|
+
x: padX, y: padY + 0.56, w: W - 2 * padX, h: 0.22,
|
|
269
|
+
fontSize: 10, fontFace: FONT_B, color: C.muted, margin: 0,
|
|
270
|
+
});
|
|
271
|
+
imgTop = padY + 0.82;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const imgData = resolveImage(vars.image);
|
|
275
|
+
if (imgData) {
|
|
276
|
+
const imgBot = H - padY - (vars.footnote ? 0.3 : 0);
|
|
277
|
+
const boxH = imgBot - imgTop;
|
|
278
|
+
const boxW = W - 2 * padX;
|
|
279
|
+
const size = getImageSize(vars.image);
|
|
280
|
+
if (size) {
|
|
281
|
+
const fit = containImage(size.w, size.h, boxW, boxH);
|
|
282
|
+
slide.addImage({ data: imgData, x: padX + fit.offX, y: imgTop + fit.offY, w: fit.w, h: fit.h });
|
|
283
|
+
} else {
|
|
284
|
+
slide.addImage({ data: imgData, x: padX, y: imgTop, w: boxW, h: boxH });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (vars.footnote) {
|
|
289
|
+
slide.addText(vars.footnote, {
|
|
290
|
+
x: padX, y: H - padY - 0.22, w: W - 2 * padX, h: 0.22,
|
|
291
|
+
fontSize: 9, fontFace: FONT_B, color: C.muted, margin: 0,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function renderConclusionSummary(slide, vars) {
|
|
297
|
+
const padX = px(64);
|
|
298
|
+
|
|
299
|
+
const logoData = resolveImage(vars.logo);
|
|
300
|
+
if (logoData) {
|
|
301
|
+
const logoBoxW = 2.5;
|
|
302
|
+
const logoBoxH = 0.7;
|
|
303
|
+
const logoSize = getImageSize(vars.logo);
|
|
304
|
+
if (logoSize) {
|
|
305
|
+
const fit = containImage(logoSize.w, logoSize.h, logoBoxW, logoBoxH);
|
|
306
|
+
slide.addImage({ data: logoData, x: (W - logoBoxW) / 2 + fit.offX, y: 0.5 + fit.offY, w: fit.w, h: fit.h });
|
|
307
|
+
} else {
|
|
308
|
+
slide.addImage({ data: logoData, x: (W - logoBoxW) / 2, y: 0.5, w: logoBoxW, h: logoBoxH });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
slide.addText(vars.heading, {
|
|
313
|
+
x: padX, y: 1.35, w: W - 2 * padX, h: 0.45,
|
|
314
|
+
fontSize: 16, fontFace: FONT_H, bold: true, color: C.fg, align: "center", margin: 0,
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const pointsStartY = 2.0;
|
|
318
|
+
const pointW = 6;
|
|
319
|
+
const pointX = (W - pointW) / 2;
|
|
320
|
+
const pointH = 0.55;
|
|
321
|
+
|
|
322
|
+
vars.points.forEach((p, i) => {
|
|
323
|
+
const py = pointsStartY + i * pointH;
|
|
324
|
+
slide.addShape(pres.shapes.LINE, {
|
|
325
|
+
x: pointX, y: py + pointH, w: pointW, h: 0,
|
|
326
|
+
line: { color: C.border, width: 0.5 },
|
|
327
|
+
});
|
|
328
|
+
slide.addText(p.label.toUpperCase(), {
|
|
329
|
+
x: pointX, y: py + 0.06, w: 0.8, h: pointH - 0.12,
|
|
330
|
+
fontSize: 9, fontFace: FONT_B, bold: true, color: C.primary, charSpacing: 1, margin: 0, valign: "top",
|
|
331
|
+
});
|
|
332
|
+
slide.addText(p.text, {
|
|
333
|
+
x: pointX + 0.9, y: py + 0.06, w: pointW - 0.9, h: pointH - 0.12,
|
|
334
|
+
fontSize: 11, fontFace: FONT_B, color: C.fg, margin: 0, valign: "top", lineSpacingMultiple: 1.3,
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function renderClosingTriad(slide, vars) {
|
|
340
|
+
const padX = px(64);
|
|
341
|
+
const leftW = (W - 2 * padX) * 0.5;
|
|
342
|
+
|
|
343
|
+
slide.addText(vars.heading, {
|
|
344
|
+
x: padX, y: 1.2, w: leftW, h: 0.8,
|
|
345
|
+
fontSize: 20, fontFace: FONT_H, bold: true, color: C.fg, margin: 0, valign: "top", lineSpacingMultiple: 1.4,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
slide.addText(vars.description, {
|
|
349
|
+
x: padX, y: 2.1, w: leftW, h: 2.8,
|
|
350
|
+
fontSize: 11, fontFace: FONT_B, bold: true, color: C.fg, margin: 0, valign: "top", lineSpacingMultiple: 1.9,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const rightX = padX + leftW + px(48);
|
|
354
|
+
const rightW = W - rightX - padX;
|
|
355
|
+
const centerX = rightX + rightW / 2;
|
|
356
|
+
const circR = 0.6;
|
|
357
|
+
const topCX = centerX;
|
|
358
|
+
const topCY = 1.5;
|
|
359
|
+
const botLCX = centerX - 1.2;
|
|
360
|
+
const botRCX = centerX + 1.2;
|
|
361
|
+
const botCY = 3.8;
|
|
362
|
+
|
|
363
|
+
[[topCX, topCY, botLCX, botCY], [topCX, topCY, botRCX, botCY], [botLCX, botCY, botRCX, botCY]].forEach(([x1, y1, x2, y2]) => {
|
|
364
|
+
slide.addShape(pres.shapes.LINE, {
|
|
365
|
+
x: x1, y: y1, w: x2 - x1, h: y2 - y1,
|
|
366
|
+
line: { color: C.border, width: 1.5 },
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const drawEntity = (cx, cy, entity) => {
|
|
371
|
+
slide.addShape(pres.shapes.OVAL, {
|
|
372
|
+
x: cx - circR, y: cy - circR, w: circR * 2, h: circR * 2,
|
|
373
|
+
fill: { color: C.bg }, line: { color: C.fg, width: 1.5 },
|
|
374
|
+
});
|
|
375
|
+
slide.addText(entity.name, {
|
|
376
|
+
x: cx - circR, y: cy - 0.15, w: circR * 2, h: 0.25,
|
|
377
|
+
fontSize: 13, fontFace: FONT_H, bold: true, color: C.fg, align: "center", valign: "middle", margin: 0,
|
|
378
|
+
});
|
|
379
|
+
if (entity.role) {
|
|
380
|
+
slide.addText(entity.role, {
|
|
381
|
+
x: cx - circR, y: cy + 0.12, w: circR * 2, h: 0.2,
|
|
382
|
+
fontSize: 8, fontFace: FONT_B, color: C.muted, align: "center", valign: "middle", margin: 0,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
drawEntity(topCX, topCY, vars.top);
|
|
388
|
+
drawEntity(botLCX, botCY, vars.bottomLeft);
|
|
389
|
+
drawEntity(botRCX, botCY, vars.bottomRight);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ─── Pattern registry ───────────────────────────────────────────────
|
|
393
|
+
const renderers = {
|
|
394
|
+
"price-before-after": renderPriceBeforeAfter,
|
|
395
|
+
"comparison-table": renderComparisonTable,
|
|
396
|
+
"image-full": renderImageFull,
|
|
397
|
+
"conclusion-summary": renderConclusionSummary,
|
|
398
|
+
"closing-triad": renderClosingTriad,
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
// ─── Main ───────────────────────────────────────────────────────────
|
|
402
|
+
let rendered = 0;
|
|
403
|
+
for (const slideData of deck.slides) {
|
|
404
|
+
const slide = pres.addSlide();
|
|
405
|
+
slide.background = { color: C.bg };
|
|
406
|
+
|
|
407
|
+
const renderer = renderers[slideData.file];
|
|
408
|
+
if (!renderer) {
|
|
409
|
+
console.warn(`⚠ No pptx renderer for pattern "${slideData.file}" — blank slide`);
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
renderer(slide, slideData.vars ?? {});
|
|
414
|
+
rendered++;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
console.log(`✓ Rendered ${rendered}/${deck.slides.length} slide(s)`);
|
|
418
|
+
|
|
419
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
420
|
+
await pres.writeFile({ fileName: outputPath });
|
|
421
|
+
console.log(`✓ Written to ${outputPath}`);
|